From 6a0caf6f3546a1e2fc457b7bdfd66abc29674192 Mon Sep 17 00:00:00 2001 From: Seenu K Date: Thu, 15 Oct 2026 10:45:09 +0530 Subject: [PATCH 1/2] Simplify group lifecycle and add API update handling --- .github/workflows/ci.yml | 2 + .github/workflows/release.yml | 19 +- CHANGELOG.md | 10 +- README.md | 50 +- biome.jsonc | 2 + build.yaml | 2 + docs/api-upgrades.md | 109 + docs/architecture.md | 42 +- docs/brand/README.md | 12 +- docs/group-lifecycle.md | 74 + docs/local-database.md | 170 +- docs/openapi.json | 100 +- docs/resetting-the-backend.md | 290 +-- docs/runbook.md | 41 +- docs/ui-ux.md | 7 +- drift_schemas/drift_schema_v1.json | 1366 +++++++--- drift_schemas/drift_schema_v10.json | 2236 ----------------- drift_schemas/drift_schema_v2.json | 1665 ------------ drift_schemas/drift_schema_v3.json | 1759 ------------- drift_schemas/drift_schema_v4.json | 1761 ------------- drift_schemas/drift_schema_v5.json | 1755 ------------- drift_schemas/drift_schema_v6.json | 1766 ------------- drift_schemas/drift_schema_v7.json | 1802 ------------- drift_schemas/drift_schema_v8.json | 1792 ------------- drift_schemas/drift_schema_v9.json | 1940 -------------- fastlane/Fastfile | 4 +- lib/application/backend_providers.dart | 17 + lib/application/ledger_providers.dart | 18 +- lib/application/local_providers.dart | 8 +- lib/application/session_providers.dart | 1 + lib/application/sync_coordinator.dart | 16 +- lib/application/sync_providers.dart | 14 + lib/data/auth/better_auth_service.dart | 2 +- lib/data/local/database.dart | 8 +- lib/data/local/open_database.dart | 8 +- lib/data/local/remove_group.dart | 28 + lib/data/local/tables.dart | 2 +- lib/data/platform/app_update_service.dart | 38 +- lib/data/push/background_handler.dart | 16 +- .../drift_activity_repository.dart | 4 +- .../repositories/drift_entry_repository.dart | 12 +- .../repositories/drift_group_repository.dart | 63 +- lib/data/repositories/group_write_access.dart | 30 + lib/data/sync/api_client.dart | 121 +- lib/data/sync/apply_changes.dart | 20 +- lib/data/sync/outbox_queue.dart | 64 +- lib/data/sync/sync_engine.dart | 137 +- lib/data/sync/wire.dart | 4 +- lib/data/web/release_updates_stub.dart | 3 + lib/data/web/release_updates_web.dart | 44 + lib/domain/export/json_export.dart | 1 - lib/domain/models/group_event.dart | 2 +- lib/main.dart | 6 + lib/presentation/app.dart | 8 +- lib/presentation/feedback.dart | 21 + lib/presentation/router.dart | 106 +- lib/presentation/screens/account_screen.dart | 41 +- lib/presentation/screens/activity_screen.dart | 8 +- .../screens/archived_groups_screen.dart | 115 - .../screens/avatar_picker_screen.dart | 9 + .../screens/entry_editor_screen.dart | 269 +- .../screens/group_detail_screen.dart | 48 +- .../screens/group_list_screen.dart | 45 +- .../screens/group_settings_screen.dart | 127 +- lib/presentation/screens/members_screen.dart | 226 +- .../screens/settle_up_screen.dart | 259 +- lib/presentation/widgets/balances_panel.dart | 10 +- lib/presentation/widgets/empty_state.dart | 2 +- lib/presentation/widgets/export_button.dart | 8 +- lib/presentation/widgets/group_access.dart | 56 + .../widgets/sync_refresh_button.dart | 5 +- .../widgets/sync_status_notice.dart | 10 + .../widgets/unsynced_changes_banner.dart | 30 +- .../widgets/update_required_notice.dart | 116 + packages/opensplit_api/lib/src/api.dart | 2 +- .../lib/src/api/account_api.dart | 2 +- .../lib/src/api/devices_api.dart | 4 +- .../lib/src/api/entries_api.dart | 2 +- .../opensplit_api/lib/src/api/groups_api.dart | 8 +- .../lib/src/api/identity_api.dart | 18 +- .../lib/src/api/invites_api.dart | 14 +- .../opensplit_api/lib/src/api/meta_api.dart | 2 +- .../lib/src/api/reference_api.dart | 6 +- .../opensplit_api/lib/src/api/sync_api.dart | 6 +- .../lib/src/model/error_code.dart | 2 + .../lib/src/model/error_error.g.dart | 1 + .../opensplit_api/lib/src/model/event.g.dart | 2 - .../lib/src/model/event_kind.dart | 4 - .../opensplit_api/lib/src/model/group.dart | 7 - .../opensplit_api/lib/src/model/group.g.dart | 6 - .../lib/src/model/group_input.dart | 19 +- .../lib/src/model/group_input.g.dart | 9 +- .../lib/src/model/group_refusal.g.dart | 1 + .../lib/src/model/member_input.dart | 12 +- .../lib/src/model/member_input.g.dart | 7 +- .../lib/src/model/push_data.g.dart | 2 - pubspec.yaml | 2 +- server/scripts/write-openapi.ts | 4 +- server/src/api/ledger.ts | 6 +- server/src/api/routing.ts | 11 + server/src/app.ts | 33 +- server/src/auth.ts | 3 +- server/src/db/group/schema.ts | 5 +- server/src/do/group/balances.ts | 4 +- server/src/do/group/changes.ts | 26 +- server/src/do/group/index.ts | 39 +- server/src/do/group/ledger.ts | 8 +- .../migration.sql | 1 - .../snapshot.json | 12 +- server/src/do/group/migrations/migrations.js | 4 +- server/src/do/group/roster.ts | 47 +- server/src/do/group/store.ts | 15 +- server/src/do/group/upkeep.ts | 63 +- server/src/openapi.ts | 7 +- server/src/paths.ts | 8 + server/src/scheduled.ts | 2 +- server/src/schemas/common.ts | 6 +- server/src/schemas/ledger.ts | 10 +- server/test/account.test.ts | 76 +- server/test/activity.test.ts | 42 +- server/test/api.test.ts | 34 +- server/test/auth.test.ts | 4 +- server/test/contract.test.ts | 84 + server/test/dormancy.test.ts | 159 +- server/test/fx.test.ts | 20 +- server/test/group.ts | 10 +- server/test/handover.test.ts | 8 +- server/test/hardening.test.ts | 12 +- server/test/identity.test.ts | 18 +- server/test/index.test.ts | 18 +- server/test/invites.test.ts | 2 +- server/test/ledger.test.ts | 2 +- server/test/lifecycle.test.ts | 109 + server/test/links.test.ts | 96 +- server/test/object.test.ts | 17 +- server/test/push.test.ts | 4 +- server/test/reauth.test.ts | 8 +- server/test/retirement.test.ts | 48 + server/test/roster.test.ts | 26 +- server/test/session.ts | 8 +- server/test/smoke.test.ts | 24 +- server/wrangler.jsonc | 2 +- site/delete-account.html | 2 +- site/privacy.html | 9 +- site/store/artifacts.json | 368 +++ site/store/feature-graphic.jpg | Bin 70604 -> 68881 bytes site/store/og-card.png | Bin 187323 -> 179657 bytes site/store/screenshot-1-groups.png | Bin 196229 -> 170439 bytes site/store/screenshot-2-expenses.png | Bin 200676 -> 185381 bytes site/store/screenshot-3-balances.png | Bin 212999 -> 194937 bytes site/store/screenshot-4-add-expense.png | Bin 129109 -> 130794 bytes site/store/screenshot-5-insights.png | Bin 168079 -> 166741 bytes site/store/web-app.png | Bin 299064 -> 277656 bytes test/application/sync_triggers_test.dart | 36 +- test/browser/release_updates_test.dart | 113 + test/browser/sync_test.dart | 168 +- test/data/api_client_test.dart | 42 +- test/data/api_retirement_test.dart | 270 ++ test/data/app_update_service_test.dart | 91 + test/data/apply_changes_test.dart | 127 +- test/data/cloudflare_integration_test.dart | 58 +- test/data/generated_migrations/schema.dart | 29 +- test/data/generated_migrations/schema_v1.dart | 955 ++++--- .../data/generated_migrations/schema_v10.dart | 1663 ------------ test/data/generated_migrations/schema_v2.dart | 1427 ----------- test/data/generated_migrations/schema_v3.dart | 1501 ----------- test/data/generated_migrations/schema_v4.dart | 1487 ----------- test/data/generated_migrations/schema_v5.dart | 1476 ----------- test/data/generated_migrations/schema_v6.dart | 1487 ----------- test/data/generated_migrations/schema_v7.dart | 1506 ----------- test/data/generated_migrations/schema_v8.dart | 1497 ----------- test/data/generated_migrations/schema_v9.dart | 1613 ------------ test/data/live_backend.dart | 6 +- test/data/local_write_atomicity_test.dart | 30 + test/data/migration_test.dart | 313 +-- test/data/outbox_test.dart | 25 +- test/data/repositories_test.dart | 37 +- test/data/sync_test.dart | 178 +- test/data/wire_test.dart | 9 +- test/presentation/api_retirement_test.dart | 142 ++ test/presentation/group_lifecycle_test.dart | 161 ++ test/presentation/navigation_test.dart | 71 +- test/presentation/sync_refresh_test.dart | 10 +- test/tool/store_artifacts_test.dart | 70 + test/tool/verify_config_test.dart | 5 +- tool/screenshots_test.dart | 14 +- tool/store_artifacts.dart | 114 + tool/store_graphics.py | 9 +- 188 files changed, 6189 insertions(+), 33195 deletions(-) create mode 100644 docs/api-upgrades.md create mode 100644 docs/group-lifecycle.md delete mode 100644 drift_schemas/drift_schema_v10.json delete mode 100644 drift_schemas/drift_schema_v2.json delete mode 100644 drift_schemas/drift_schema_v3.json delete mode 100644 drift_schemas/drift_schema_v4.json delete mode 100644 drift_schemas/drift_schema_v5.json delete mode 100644 drift_schemas/drift_schema_v6.json delete mode 100644 drift_schemas/drift_schema_v7.json delete mode 100644 drift_schemas/drift_schema_v8.json delete mode 100644 drift_schemas/drift_schema_v9.json create mode 100644 lib/data/local/remove_group.dart create mode 100644 lib/data/repositories/group_write_access.dart delete mode 100644 lib/presentation/screens/archived_groups_screen.dart create mode 100644 lib/presentation/widgets/group_access.dart create mode 100644 lib/presentation/widgets/update_required_notice.dart rename server/src/do/group/migrations/{20261005061632_gigantic_mother_askani => 20261014133419_previous_gorilla_man}/migration.sql (99%) rename server/src/do/group/migrations/{20261005061632_gigantic_mother_askani => 20261014133419_previous_gorilla_man}/snapshot.json (99%) create mode 100644 server/src/paths.ts create mode 100644 server/test/contract.test.ts create mode 100644 server/test/lifecycle.test.ts create mode 100644 server/test/retirement.test.ts create mode 100644 site/store/artifacts.json create mode 100644 test/browser/release_updates_test.dart create mode 100644 test/data/api_retirement_test.dart create mode 100644 test/data/app_update_service_test.dart delete mode 100644 test/data/generated_migrations/schema_v10.dart delete mode 100644 test/data/generated_migrations/schema_v2.dart delete mode 100644 test/data/generated_migrations/schema_v3.dart delete mode 100644 test/data/generated_migrations/schema_v4.dart delete mode 100644 test/data/generated_migrations/schema_v5.dart delete mode 100644 test/data/generated_migrations/schema_v6.dart delete mode 100644 test/data/generated_migrations/schema_v7.dart delete mode 100644 test/data/generated_migrations/schema_v8.dart delete mode 100644 test/data/generated_migrations/schema_v9.dart create mode 100644 test/presentation/api_retirement_test.dart create mode 100644 test/presentation/group_lifecycle_test.dart create mode 100644 test/tool/store_artifacts_test.dart create mode 100644 tool/store_artifacts.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0dd5e83..cf86b09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,8 @@ jobs: # only the former loads the plugins named in analysis_options.yaml -- so # every riverpod_lint rule was being skipped here while passing locally # in an IDE that does load them. + - name: Verify store artwork matches the current UI and assets + run: dart run tool/store_artifacts.dart --check - run: dart analyze --fatal-infos - run: flutter test --exclude-tags integration - name: Test domain logic and refresh UI in Chrome diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b3e1de..ef13ea3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,10 +11,10 @@ on: default: false update_priority: description: >- - Play in-app update priority, 0-5. 0 updates quietly in the - background; 4 and up interrupt testers with a blocking update they - cannot dismiss. Use 4 only when the server can no longer talk to the - version being replaced. + Play in-app update priority, 0-5. The app can offer an immediate + update at 4 or above when Play makes it available and permits the + flow. Use 4 for breaking releases; priority alone does not block + clients or protect data during review or installation. type: choice default: "0" options: ["0", "1", "2", "3", "4", "5"] @@ -183,9 +183,9 @@ jobs: run: test "$(gh api "repos/$GITHUB_REPOSITORY/commits/main" --jq .sha)" = "$GITHUB_SHA" # One step, one version: the Worker script and everything in build/web go # up together and become live together. That is the reason the bundle is - # built in this job rather than deployed from somewhere else — a front end - # that can be newer or older than the API it talks to is a class of bug - # this arrangement simply does not have. + # built in this job rather than deployed from somewhere else. Already open + # web tabs and installed Android builds can still use an older contract; + # API retirement and client update handling must account for those. # # The Worker, the D1 database, the KV namespace and the custom domain all # have to exist already; see docs/runbook.md. Creating them is @@ -247,9 +247,8 @@ jobs: # project's closed track was created by hand and carries its own name. PLAY_TRACK: ${{ vars.PLAY_TRACK || 'alpha' }} # Play's 0-5 in-app update priority, set per release and only - # settable through the Publishing API. 0 is an ordinary background - # update; 4 and up interrupt testers with a blocking flow, which is - # for a build the server can no longer talk to an older client from. + # settable through the Publishing API. The installed app chooses the + # update flow; priority alone cannot force installation or block use. PLAY_UPDATE_PRIORITY: ${{ inputs.update_priority || vars.PLAY_UPDATE_PRIORITY || '0' }} # Written above, from CHANGELOG.md and the committed artwork. # en-US is the store listing's language; images affect the listing diff --git a/CHANGELOG.md b/CHANGELOG.md index d7ee40e..1dafc1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,17 @@ What changed in each version of the app, newest first. The format is [Semantic Versioning](https://semver.org/), and the file is kept by [cider](https://pub.dev/packages/cider): see "Versions" in docs/runbook.md. -## [Unreleased] +## [4.0.0] - 2026-10-15 ### Changed +- When a numbered API version is retired, saved data stays readable and pending uploads pause. Update OpenSplit through Play or restart into the new web release to edit again. +- This testing release opens a fresh local ledger and requires signing in again. Old device databases remain unused; older builds can no longer change server data. +- The archive option has been removed. Leaving a group ends access on all devices. +- The last account holder can leave a fully settled group, permanently deleting it. Groups with active account holders are retained without an inactivity deadline. - Groups have larger names and avatars with quieter cover art. Forms use consistent filled fields, and app wording is shorter. ### Fixed +- Departed members cannot open group screens or edit cached data. Confirmed access loss clears the local cache without a retry loop. Stale group and member edits cannot overwrite newer versions. +- Store screenshots and the feature graphic are refreshed together, with CI detecting stale artwork. - Improved browser database transaction reliability. ### Security @@ -100,7 +106,7 @@ What changed in each version of the app, newest first. The format is ### Added - The first build, to Play internal testing. -[Unreleased]: https://github.com/eigeninteractive/opensplit/compare/v3.1.0...HEAD +[4.0.0]: https://github.com/eigeninteractive/opensplit/compare/v3.1.0...v4.0.0 [3.1.0]: https://github.com/eigeninteractive/opensplit/compare/v3.0.0...v3.1.0 [3.0.0]: https://github.com/eigeninteractive/opensplit/compare/v2.0.0...v3.0.0 [2.0.0]: https://github.com/eigeninteractive/opensplit/compare/v1.0.0...v2.0.0 diff --git a/README.md b/README.md index b65f7b8..2504081 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ Worker over local D1, KV and Durable Object storage. There is no fake server: the rules they exercise are the server's own. Nothing touches a Cloudflare account and no credentials are needed. -They skip themselves unless an OpenSplit Worker answers `/api/health`, so +They skip themselves unless the current OpenSplit API answers its health check, so `flutter test` stays green without one. To use another port (`pnpm exec wrangler dev --env test --port 8797`), pass `--dart-define=API_BASE_URL=http://127.0.0.1:8797`. CI runs them in the `backend` job with `--dart-define=REQUIRE_BACKEND=true`, so a @@ -160,9 +160,13 @@ dart run drift_dev schema dump lib/data/local/database.dart drift_schemas/ dart run drift_dev schema generate drift_schemas/ test/data/generated_migrations/ ``` -Until the first public release, an upgrade rebuilds the local database and -re-syncs, which loses anything recorded offline and never pushed. See -[docs/local-database.md](docs/local-database.md). +The local storage baseline opens an `opensplit-v1-` database at +schema version 1 and synchronizes it from the server. Old databases are unused; +their offline edits do not transfer. Future upgrades must keep this database +name and migrate local data and queued edits before sync resumes. API retirement +pauses pending uploads; replay through a new API requires that release's +mapping and migration tests. See [docs/local-database.md](docs/local-database.md) +and [docs/api-upgrades.md](docs/api-upgrades.md). The domain layer is also run in a real browser: @@ -302,11 +306,10 @@ package, and only `tool/build_web.dart` may produce a deployable web directory. It verifies the configuration, builds the client, and then has Workbox precache the finished release, bundling Firebase's public identifiers into the worker. CI uses structurally valid inert identifiers to prove the release build. After every CI gate passes, pushes to `main` build with real -production variables, deploy the Worker and the bundle together, and distribute -a signed AAB to Play closed testing. Release reruns the same CI checks before -publishing. -A manual **Release** run with **deploy unchecked** creates -artifacts only, for first-upload bootstrap. +production variables, deploy the Worker and web bundle together, and distribute +a signed AAB and listing artwork to Play closed testing. Release reruns the same +CI checks before publishing. A manual **Release** run with **deploy unchecked** +creates artifacts only, for first-upload bootstrap. Every integration is off unless configured, and hidden rather than shown broken: @@ -318,6 +321,11 @@ Every integration is off unless configured, and hidden rather than shown broken: | `FCM_VAPID_KEY` | Web push, in addition to the above | | `LINK_HOST` | The host used in invite links | +The app API uses one base path, defined in `server/src/paths.ts`. OpenAPI +`servers.url` carries it into the generated Dart client; operation paths below +are relative to that base. `API_BASE_URL` configures only the backend origin. +To change the contract version, edit the base path and regenerate the client. + ### Exchange rates Rates are fetched by the server, never by devices — so every member of a group @@ -334,7 +342,7 @@ cd server && pnpm exec wrangler secret put EXCHANGERATE_API_KEY --env="" A daily cron (`0 4 * * *`, after ECB publishes) calls the `Fx` Durable Object, which is the **only writer** of rates. It holds the history in its own SQLite and publishes one blob per month to KV, where every device reads it through -`GET /api/fx`. +`GET /fx`. A singleton object rather than a scheduled function writing KV directly, because KV is last-write-wins: the daily run and an on-demand backfill @@ -362,7 +370,7 @@ already covered would otherwise make the `source` stamped on somebody's converted expense quietly wrong. **Backdated entries fetch on demand.** Recording an expense on a date the app -has never priced posts to `/api/fx/backfill`, which fetches that day and caches +has never priced posts to `/fx/backfill`, which fetches that day and caches it for everyone. The object refuses dates it can already answer, repeats within the hour, futures, and dates chased more than five times — some days are unanswerable, and without a ceiling every device retries them forever against @@ -439,7 +447,7 @@ project made: 1. **Google Auth Platform → Branding** (formerly the OAuth consent screen). App name, support email, developer contact. Nothing works until this exists. 2. **Credentials → Create credentials → OAuth client ID → Web application.** - Add `https:///api/auth/callback/google` as an authorized redirect + Add `https:///api/v1/auth/callback/google` as an authorized redirect URI, and your site origin under authorized JavaScript origins. Its **Client ID** is `GOOGLE_WEB_CLIENT_ID` — one value, used on both platforms, because the Worker verifies the ID token's audience against the web client @@ -486,7 +494,7 @@ The app deliberately does *not* create a session on startup. It used to, and that broke the arrival it was meant to protect: somebody who already had an account and tapped an invite link had the single-use token spent by a throwaway anonymous account, and no way into the group afterwards. `/join/:token` now -reads the link with `GET /api/links/{token}`, which is the one route that runs +reads the link with `GET /links/{token}`, which is the one route that runs with no session at all, shows what the link is for, and joins only after the identity question has an answer. @@ -517,7 +525,7 @@ Arriving on a group link asks which of the group's unclaimed placeholders you are, if any. Claiming one is the same single-column update a named invite performs — no expense is rewritten and no balance moves — and it is what stops one shared link turning a group of six into a group of twelve. -`GET /api/links/{token}/placeholders` deliberately **requires** a session, +`GET /links/{token}/placeholders` deliberately **requires** a session, unlike the preview beside it: those are other people's names, which is more than the token itself implies, and it is asked after an account has been chosen rather than before. @@ -578,7 +586,7 @@ nothing else changes, which is why a deployment with no push configured still records expenses normally. **Three kinds wake a device:** an expense, somebody arriving, somebody leaving. -Not renames, archives or links — those belong in the activity feed, which is +Not renames or links — those belong in the activity feed, which is read on purpose, rather than on a lock screen. `link_created` in particular would wake a whole group to say that one of them tapped Share. @@ -711,7 +719,7 @@ adapter tests: ```bash pnpm dev --test-scheduled # exposes the handler curl 'http://127.0.0.1:8787/cdn-cgi/handler/scheduled?cron=0+4+*+*+*' -curl 'http://127.0.0.1:8787/api/fx?since=2026-01-01' +curl 'http://127.0.0.1:8787/api/v1/fx?since=2026-01-01' ``` This reaches the real ECB feed, and with no `EXCHANGERATE_API_KEY` set the log @@ -733,11 +741,11 @@ reviewer and a Google OAuth reviewer can all read what the app is. With the client at the root, the public face would be a sign-in screen, which OAuth branding verification rejects. -Its pictures are generated rather than drawn. `flutter test -tool/screenshots_test.dart` renders the real screens over demo data into -`site/store/`, and `python3 tool/store_graphics.py` frames one of them as the -link preview and the Play feature graphic; rerun both after a visible UI -change. The pages use the fonts the app bundles, which `tool/build_web.dart` +Its pictures are generated rather than drawn. `dart run +tool/store_artifacts.dart` renders the real screens over demo data into +`site/store/`, then builds the link preview, Play feature graphic and email +wordmark. CI checks the saved source and output hashes, so app changes require +a complete artwork refresh. The pages use the fonts the app bundles, which `tool/build_web.dart` copies to `/fonts`, so the site asks no other origin for anything. Nothing in Dart knows about the prefix. `--base-href=/app/` puts it below the diff --git a/biome.jsonc b/biome.jsonc index 339324e..fc08659 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -24,6 +24,8 @@ "!server/worker-configuration.d.ts", // The API contract, emitted from the Worker's Zod schemas. "!docs/openapi.json", + // Store artwork provenance, emitted after a complete render. + "!site/store/artifacts.json", // `drift_dev schema dump`. "!drift_schemas", // Drift's release file, which CI compares byte for byte. diff --git a/build.yaml b/build.yaml index 8e192b9..1a12e01 100644 --- a/build.yaml +++ b/build.yaml @@ -21,6 +21,8 @@ targets: drift_dev:analyzer: enabled: true options: &drift_options + # Match AppDatabase.options so schema dumps describe actual storage. + store_date_time_values_as_text: true sql: dialect: sqlite options: diff --git a/docs/api-upgrades.md b/docs/api-upgrades.md new file mode 100644 index 0000000..9fc0b79 --- /dev/null +++ b/docs/api-upgrades.md @@ -0,0 +1,109 @@ +# API retirement and app updates + +`server/src/paths.ts` defines one numeric `currentApiVersion` and derives the +single supported application API base, `/api/vN`. Positive integer versions +below it are retired and receive HTTP 410 with the stable `update_required` +error and `Cache-Control: no-store`, before reaching account or ledger services. + +Unversioned paths, malformed versions, unavailable future versions and unknown +operations within the current version return ordinary `404 / not_found`. +At the v1 baseline, no numbered API has been retired yet. Old unversioned apps +cannot reach data handlers and receive no special upgrade handling. + +For example, when the current version becomes 2: + +| Request | Result | +| --- | --- | +| `/api/v1/...` | `410 / update_required` | +| Valid `/api/v2/...` operation | Normal handling | +| Unknown `/api/v2/...` operation | `404 / not_found` | +| `/api/v3/...` | `404 / not_found` | +| Unversioned `/api/...` | `404 / not_found` | + +The client never guesses incompatibility from an arbitrary 404, redirects an +old request, or changes its own API version. + +## Read-only access + +One `ApiAccess` flag is shared by the client, local write queue and UI. +An explicit `update_required` refusal turns it on and remembers it in preferences, +keyed by the backend origin and API base. Restarting offline therefore keeps +editing disabled. A compatible app uses the new API base and a fresh flag. +There is no rollback recovery, periodic compatibility probe, extra endpoint or +database schema. Before the first refusal, ordinary offline use still works. + +Once refused, the app stops network requests and automatic sync, disables edits, +and keeps saved groups, expenses, payments, history and exports readable. The +persistent notice offers Update OpenSplit. A queued write is neither +acknowledged nor dead-lettered because of retirement; its contents and retry +metadata stay untouched. The queue also checks the flag inside the caller's +transaction so a stale editor cannot commit a new financial edit. Background +sync observes the same refusal and leaves pending work intact. + +## Pending edits after an app update + +Expenses and their outbox markers are saved together in the local database. +An outbox item identifies a local record; it does not store an HTTP request +addressed to an old API. After updating, the current client reads that record +and builds a request for its own contract. + +Preserving an edit during retirement does not guarantee that a newer API will +accept it. A URL or request-format change may need only a new request mapping. +A change to the stored model or its meaning needs a release-specific migration +before synchronization resumes. Future upgrades must retain the same database +name and migrate pending data rather than reset the ledger. + +Every breaking release must test upgrading supported old database versions with +pending edits, including relevant creates, updates, deletions and dependencies. +Verify amounts, participants, IDs and queue ordering survive, then exercise the +new API mapping and its success, conflict and refusal responses. No generic +cross-version command format or conversion framework is provided. + +If an edit cannot be translated safely, that release needs explicit handling +that keeps it available and explains the correction or manual recovery needed. +The retirement flag does not implement this. Today a permanent server refusal +uses the failed-change UI, a stale expense keeps an attempted snapshot as a +conflict, and a local serialization error can enter automatic retry handling. +Do not ship a breaking release that leaves old pending records in that retry +loop or silently discards them. + +The existing access policy still applies: confirmed loss of membership or group +deletion clears that group's cached data and pending edits. The initial +storage baseline intentionally abandons the earlier database and its +queue; it does not transfer old offline work into the new baseline. + +## Installing the update + +Android reuses `AppUpdateService` and the existing `in_app_update` plugin. The +required-update action checks Play and starts or resumes an immediate update +when allowed, regardless of release priority. Unavailability or failure opens +the Play listing. Canceling keeps the app read-only and leaves Update available. +An update must be released to that user before Play can install it. + +Ordinary optional update offers still check Play on launch and, with a four-hour +interval, on resume. Priority 4 or above selects the immediate flow if Play +permits it; priority alone does not enforce compatibility. + +Web checks the service worker for a release, waits for installation, sends +`SKIP_WAITING`, and reloads after controller change. A release already activated +by another tab only needs a reload. If no new release is available, the notice +stays and the user can try again. OPFS, saved edits and browser app storage are +not cleared. The existing optional Restart offer remains available as well. + +Already installed old builds cannot gain this handling from a server response. +Their unsupported requests are refused safely, but the clear notice and queue +preservation behavior only exist in clients that ship this implementation. + +## Release policy + +The app version, API contract version and local schema version are independent. +Compatible API changes keep the same API base. Future local schema migrations +keep `opensplit-v1-` and increment the Drift schema version inside it. + +For future production retirements, make a compatible app available before +removing its predecessor, or accept the period in which users cannot connect. +An update prompt cannot shorten Play review or install an unreleased build. + +See [Google Play in-app updates](https://developer.android.com/guide/playcore/in-app-updates), +[availability and cancellation](https://developer.android.com/guide/playcore/in-app-updates/kotlin-java), +and [Workbox update activation](https://developer.chrome.com/docs/workbox/handling-service-worker-updates). diff --git a/docs/architecture.md b/docs/architecture.md index 28fbda2..a0d41ab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -70,21 +70,15 @@ lost response is. ## What each store holds -**The group object** (`server/src/db/group/schema.ts`) — `members`, `entries`, -`entry_payers`, `entry_shares`, `events`, `invites`, `group_link`, plus four -tables that exist because the object is a little machine as well as a table: -`counter` (the sequence allocator), `meta` (the group row itself), `tombstone` -(what is left after a purge, so a device that still holds a copy is told to drop -it rather than being refused forever), `outbox` (writes owed to D1), -`schedule` (the dormancy alarm) and `activity` (when a member last changed -anything, which is what quiet is measured from; housekeeping never writes it). - -A purge happens when a group is archived, settled in every currency and quiet -for a year, or at once when its last account holder deletes their account. -Dropping it on the device is not a soft delete: `applyGroupChanges` deletes the -local group row and the foreign keys cascade to its members, expenses, payers, -shares and activity. An edit that device never pushed goes with it. The copy -that survives is whatever somebody exported beforehand. +**The group object** (`server/src/db/group/schema.ts`) holds shared group, +membership and financial rows, events and invite links. Only active account +members can read or edit it; leaving removes access without copying history. + +`counter` allocates shared sequence numbers, `tombstone` reports a permanently +deleted group, `outbox` carries derived index writes to D1, and `schedule` +retries those writes. A group is deleted when its last active account holder +leaves or deletes their account. Inactivity does not affect retention. +See [Group lifecycle](group-lifecycle.md). **D1** (`server/src/db/d1/schema.ts`) — `profiles`, `memberships`, `device_tokens`, `link_tokens`, `counters` (the profile feed's version), plus @@ -159,7 +153,7 @@ Drizzle tables ──drizzle-orm/zod──▶ Zod rows, and request bodies picke - **The push message is a contract type** (`PushData`), registered as a component though no route returns it, and parsed on the device with the generated class. -- **Every session operation is an `/api/identity/*` route** returning contract +- **Every session operation is an `/identity/*` route** returning contract types (`Session`, `IdentityOutcome`, `GoogleRedirect`), each calling Better Auth's server API. Better Auth's own HTTP handler is mounted only for the OAuth callback Google redirects to. Its `openAPI()` plugin emits 3.1 with @@ -306,8 +300,11 @@ holds a bearer token. The token arrives in the `set-auth-token` **response header**; the `token` field in the response body is the unsigned first half of one and is not a credential. -**The identity endpoints are ours, not Better Auth's** — `/api/identity/ -guest`, `/session`, `/sign-out`, `/google`, `/google/redirect`, `/email`, +Operation paths are relative to the base in `server/src/paths.ts`. The same +base is emitted as OpenAPI `servers.url` and used by the generated client. + +**The identity endpoints are ours, not Better Auth's** — under `/identity`, +`/guest`, `/session`, `/sign-out`, `/google`, `/google/redirect`, `/email`, `/email/verify`, `/reauth`, `/reauth/verify`. Attaching an identity to a guest session and signing in to an existing account are opposite outcomes: one keeps the account id, the other replaces the session. Better Auth's own endpoints will @@ -370,12 +367,9 @@ older than a day does not answer for that day, on the server or the device. accounts (over ninety days old, in no group, no session used in ninety days) and finishes any account deletion a request started and did not complete. -There is deliberately no nightly dormancy sweep. A Durable Object sets its own -alarm, so each group carries its own clock: one wake-up in three months rather -than ninety nightly scans that find nothing to do. The alternative — a cron -that asks every group whether it has gone quiet — is a fan-out proportional to -the number of groups, paid every night, to discover that almost none of them -have. +Group history has no inactivity deadline. Durable Object alarms retry pending +index work. Existing archive and purge deadlines are removed by the lifecycle +migration; they cannot erase history belonging to an account. --- diff --git a/docs/brand/README.md b/docs/brand/README.md index 03a2429..1ed519c 100644 --- a/docs/brand/README.md +++ b/docs/brand/README.md @@ -73,7 +73,11 @@ never set it on a photograph. ## Icons and splash -Generated, never edited by hand: +Generated, never edited by hand. For product screenshots and their derivatives, +run **`dart run tool/store_artifacts.dart`**. It runs both producers below and +records hashes only after every output succeeds. `--check` validates that record +in CI. Regenerate after changing app code, assets or the capture toolchain; +individually rendering screenshots does not update the complete artwork record. | Command | Writes | From | |---|---|---| @@ -98,6 +102,12 @@ splash for the same reason. | Feature graphic | 1024 × 500, JPEG | `site/store/feature-graphic.jpg` | | Phone screenshots | 1080 × 1920, PNG | `site/store/screenshot-1-groups.png` to `-5-insights.png`, in that order | +The feature graphic is uploaded as `images/featureGraphic.jpg` alongside the +screenshots. Google Play uses it in preview-video and promotional placements; +it is not a guaranteed permanent banner on the public listing. Verify upload +results in the release log or Play Console store listing, independently of +where Play chooses to display it. See [Google Play preview asset guidance](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en-GB). + All of it is generated (above). To package the committed assets and Cider notes: ```sh diff --git a/docs/group-lifecycle.md b/docs/group-lifecycle.md new file mode 100644 index 0000000..30d4402 --- /dev/null +++ b/docs/group-lifecycle.md @@ -0,0 +1,74 @@ +# Group lifecycle + +Active membership grants access to the current group. Leaving ends that access. +There is no archive preference, closed state, past-groups screen or departure +snapshot. The member's `leftAt` records participation; it is not an entitlement +to read the group's history. + +## Leaving and retention + +A departing member must be settled in every currency. Their member row and +financial records remain for the other participants. They can rejoin through a +valid link and then see the group's current data, including its history. + +If the last active account holder leaves, the entire group must be settled, +including placeholders. The same transaction deletes the group and its financial +records, revokes its links and stages removal of its discovery index. A minimal +tombstone prevents old clients or queued writes from recreating a deleted group. +Leaving requires confirmation, which explains lost access and, for the last +account holder, permanent deletion. Users can export before leaving. + +A group's age never deletes it. Only active account holders keep it alive; +placeholders and former members cannot access or manage it. The object's alarm +exists only for outstanding index delivery retries. + +## Account deletion + +Deleting an account changes its member into an ordinary placeholder under its +last display name. Existing departure status is preserved. Active placeholders +can receive expenses and be claimed. Balances and shared records remain for +other active account holders. If no active account holder remains, the group is +permanently deleted. Account deletion does not require settlement. + +Guest-to-account transfer moves active access with the person. If the account +already has a departed member row, signing in as an active guest restores that +account's access and leaves the guest's separate financial identity as a +placeholder, just as for other duplicate memberships. + +## Offline changes and concurrency + +Leaving is queued locally. The group disappears from the list and all its +routes become unavailable immediately. The cache remains only until the server +acknowledges departure, after which the group, related financial data and queued +edits are removed together. Other devices remove their copies when they next +sync and receive `not_member` or a deletion tombstone. An offline device cannot +learn about a departure on another device until it reconnects. + +A retry after a lost leave response either acknowledges the already-departed +member or sees the deleted-group tombstone; both remove the local cache. If the +server refuses a departure because a newer balance or member update intervened, +the local member is made active again and the refusal is shown for review. +Discarding the refused change refreshes the authoritative state before a new +attempt. Access loss itself does not create a retryable sync conflict. + +Group settings and member updates carry their base sequence. Stale edits are +refused instead of overwriting newer versions. Expense conflicts keep their +existing explicit conflict workflow. Repository writes and every group route +check membership. Applying server responses checks the sync session inside the +same database transaction, so sign-out cannot be undone by an in-flight response. + +## Schema + +The current schema has no archive column and includes outbox refusal categories. +The local ledger starts at schema version 1 in `opensplit-v1-`. +Old `opensplit-` databases remain untouched and unused, including +their cursors and queued edits. The updated app synchronizes a fresh ledger +from the server. Only the new v1 snapshot is retained. The server group schema +is one initial migration, with no archive column or inactivity schedules. +No lifecycle tables or history copies are introduced. Unversioned API requests +never reach account or group handlers. + +Regression coverage includes create/delete/last departure, lost leave responses, +offline departure and access loss, rejected departures, rejoining current data, +stale writes, account deletion, placeholder claiming, local storage isolation and +inaccessible group routes. diff --git a/docs/local-database.md b/docs/local-database.md index 93d97b8..c08270e 100644 --- a/docs/local-database.md +++ b/docs/local-database.md @@ -1,122 +1,70 @@ # The local database -Everything the app renders comes from a Drift/SQLite database on the device. -This file covers the two things about it that are easy to get wrong and -expensive to discover late. - ---- - -## Bump `schemaVersion` on any change to a table - -`AppDatabase.schemaVersion` is not bookkeeping. SQLite keeps the number in the -file, Drift compares it against the one compiled into the app, and **if the two -agree no migration hook runs at all**. - -So an install whose schema changed underneath it without a bump opens its old -database, is told nothing is wrong, and fails on the first query against a table -that is not there. The integer is the only thing standing between a schema -change and that crash — not the migration code, which never runs. - -Bumping costs a tester one re-sync, so the right instinct is to bump on any -doubt. After bumping: - -```bash +The app renders from a per-account Drift/SQLite ledger on the device. Its stable +storage name is `opensplit-v1-` on Android and in browser OPFS. +The `v1` label identifies this storage baseline and stays fixed when the schema +version changes. + +## Storage baseline + +The new ledger starts at schema version 1. The updated app never opens the old +`opensplit-` databases. Those files remain untouched in app storage; +no table deletion, legacy migration or data copying runs. + +On first open, Drift creates the current schema in the new database. Reference +data and the restored server ledger download after signing in. Cursors and the +outbox start empty, so old queued edits cannot enter the new sync session. +Sessions live outside the database; the server reset invalidates them, and normal +session revalidation signs the device out. No manual storage clearing or +reinstall is required. + +Edits that never reached the server stay in the unused old database and are not +imported. Sync pending changes before resetting server storage. Reopening the +new database preserves its data and queued edits. + +## Schema changes + +`AppDatabase.schemaVersion` describes the structure inside the database. It does +not select a database file. Bump it whenever a table changes, keep the same +storage name, and add explicit incremental migrations before releasing. +There is no upgrade handler at the v1 baseline because no earlier schema exists +for this database. Drift's default upgrade handler refuses missing migrations. + +For v2 and later, use Drift's step-by-step migration helpers and test upgrades +from supported versions, including users who skip releases. Preserve local data +and pending writes; starting a fresh database is specific to the initial baseline. +New installations create the latest schema directly. + +API version numbers are separate from local schema versions. Reopening a ledger +retains pending rows, but does not translate them for a changed server contract. +Each breaking release must test its new request mapping and any necessary data +migration with pending edits. See [API retirement and app updates](api-upgrades.md). + +`drift_schemas/drift_schema_v1.json` is a generated schema description, not a +user database or an automatic migration. Generate and retain each released +snapshot and its test fixtures: + +```sh dart run drift_dev schema dump lib/data/local/database.dart drift_schemas/ dart run drift_dev schema generate drift_schemas/ test/data/generated_migrations/ ``` -`test/data/migration_test.dart` fails if the committed snapshot no longer -matches the tables in code, which catches a change nobody recorded. It cannot -catch a snapshot re-dumped at the same version, so that is the one thing to -watch: once a version has been pushed, a further change is a new version. - -### Why a bump is safe right now - -`onUpgrade` delegates to Drift's own `destructiveFallback`: every declared -entity is dropped and recreated, so the device starts empty and re-syncs. Only -its upgrade step is borrowed, because that getter returns a whole -`MigrationStrategy` and using it wholesale would replace `beforeOpen` too — -which is where foreign keys, WAL and the session resume are set. That is a **pre-release policy**, and it is fine only while -the installs are testers who can lose their local copy without losing anything: -the ledger is on the server too. - -What it throws away is the outbox — writes made offline and never pushed, which -by definition exist nowhere else. Before anybody who is not a tester installs -the app, this has to become a set of ordinary migrations that preserve data. - ---- - -## Never reuse the name of a removed table - -**This is the rule to remember.** If a table is removed from `tables.dart`, its -name is spent. Do not give it to a different table later. - -The rebuild drops what the schema *declares*. A table the schema has stopped -declaring is never dropped, so it survives on every device that upgrades rather -than reinstalls — with its rows. `entry_snapshots` is the one that exists today, -left behind when the activity record became `group_events`. - -An empty table nobody reads costs a few kilobytes and is not worth more code to -remove. The hazard is what happens if the name comes back: - -> `Migrator.createAll()` issues **`CREATE TABLE IF NOT EXISTS`**. - -So a new table reusing an old name would not be created and would not error. The -app would bind to the orphan — old columns, missing columns, no message — and -fail somewhere far away from the cause. That is the failure this rule exists to -prevent, and it is silent, which is why it is written down rather than left to -be noticed. - -`migration_test.dart` asserts the current set of orphans is exactly -`{entry_snapshots}`, so a second one cannot appear unnoticed. If you add to that -set deliberately, add it there and add it here. - -### The orphan keeps its rows - -`forgetLocalLedger` deletes by name, so signing out does not clear a table it no -longer knows about. That is tolerable because the database file is keyed per -account — those are the same person's rows in a file only they open — and it -would not be tolerable in a shared file. It is another reason the rule above is -about names rather than tidiness. - ---- - -## Full-text search lives in a `.drift` file - -`lib/data/local/search.drift` declares the fts5 index and the three triggers -that keep it in step with `entries`. - -It is there rather than in `database.dart` because **Drift cannot express an -fts5 table in the Dart table DSL**, and being a declared entity is what makes -the migration drop and recreate it like anything else. - -That matters. An external-content fts5 index stores terms against `entries` -rowids; drop and recreate `entries` underneath a surviving index and every rowid -points at nothing, and because it is created with `IF NOT EXISTS` a stale one is -never replaced. Searching would then return hits for expenses that are not -there, which a test pins. - -### It needs `build.yaml` +CI regenerates these files and checks for differences. Keep the v1 baseline when +testing future migrations. Earlier storage snapshots are not needed because the +app does not open those databases. See [Drift migrations](https://drift.simonbinder.eu/migrations/) +and [schema exports](https://drift.simonbinder.eu/migrations/exports/). -Drift's sqlite module list is empty by default, so `drift_dev` cannot parse -`CREATE VIRTUAL TABLE ... USING fts5` at all. It reports the table as an element -it could not find, which reads like a syntax error and is not one. +`build.yaml` and `AppDatabase.options` both specify ISO-8601 text timestamps. +The generated snapshot therefore describes the same representation the runtime +uses. -The shape matters as much as the contents: `sqlite: modules:` is accepted by -`build_runner` and then **rejected outright** by `drift_dev schema dump`, which -refuses the `sqlite` field beside `sql`. Everything has to go under -`sql.options`, as it does in `build.yaml`. +## Search -`build.yaml` also runs Drift as its `not_shared` builder, which writes -`database.drift.dart` as a plain part. That is what lets riverpod_generator see -the row classes providers return. If `build_runner` ever reports an -`InvalidOutputException` after the API client is regenerated, delete -`.dart_tool/build` and run it again. +`lib/data/local/search.drift` declares the FTS5 index and the triggers that keep +it in step with expenses. Drift creates them with the rest of the fresh schema. -### There is no server counterpart +Drift's SQL parser needs FTS5 under `sql.options.modules` in `build.yaml`. +The `not_shared` builder produces `database.drift.dart` as a plain part, allowing +Riverpod's generator to see the row classes. -The server has no search of any kind and no index that would support one. -Search never leaves the device: no endpoint, no query cost, and it works with no -connection. That `search.drift` has no mirror on the server is the design, not -an omission — a full-text index there would be a second copy of the journal that -could disagree with the one people actually read from. +Search stays on the device; the server has no search endpoint or matching index. diff --git a/docs/openapi.json b/docs/openapi.json index d7f6008..54a0dc0 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1,9 +1,14 @@ { "openapi": "3.0.3", + "servers": [ + { + "url": "/api/v1" + } + ], "info": { "version": "1.0.0", "title": "OpenSplit", - "description": "The server stores rows and enforces one invariant. It computes nothing: splitting, folding balances, simplifying debts and analytics all happen on the device." + "description": "The server stores financial history and enforces membership, settlement and write concurrency rules. Splitting, debt simplification and analytics happen on the device." }, "components": { "securitySchemes": { @@ -91,12 +96,6 @@ "format": "date-time", "example": "2026-09-23T11:20:41.865Z" }, - "archivedAt": { - "type": "string", - "nullable": true, - "format": "date-time", - "example": "2026-09-23T11:20:41.865Z" - }, "updatedAt": { "type": "string", "format": "date-time", @@ -124,7 +123,6 @@ "createdBy", "createdAt", "lastActivityAt", - "archivedAt", "updatedAt", "seq" ] @@ -429,8 +427,6 @@ "member_left", "member_renamed", "group_renamed", - "group_archived", - "group_restored", "link_created", "link_revoked" ] @@ -520,6 +516,7 @@ "malformed", "no_session", "not_found", + "update_required", "internal", "identity_already_in_use", "auth_failed", @@ -1304,12 +1301,6 @@ "simplifyDebts": { "type": "boolean" }, - "archivedAt": { - "type": "string", - "nullable": true, - "format": "date-time", - "example": "2026-09-23T11:20:41.865Z" - }, "avatarKind": { "$ref": "#/components/schemas/AvatarKind" }, @@ -1350,6 +1341,12 @@ "type": "string", "minLength": 1, "maxLength": 100 + }, + "baseSeq": { + "type": "integer", + "nullable": true, + "minimum": 0, + "example": 412 } }, "required": [ @@ -1357,7 +1354,6 @@ "defaultCurrency", "isDirect", "simplifyDebts", - "archivedAt", "avatarKind", "avatarColor", "avatarEmoji", @@ -1366,7 +1362,8 @@ "coverKind", "coverPhoto", "creatorId", - "creatorName" + "creatorName", + "baseSeq" ] }, "MemberInput": { @@ -1388,12 +1385,19 @@ "nullable": true, "format": "date-time", "example": "2026-09-23T11:20:41.865Z" + }, + "baseSeq": { + "type": "integer", + "nullable": true, + "minimum": 0, + "example": 412 } }, "required": [ "displayName", "upiVpa", - "leftAt" + "leftAt", + "baseSeq" ] }, "EntryInput": { @@ -2005,7 +2009,7 @@ "parameters": {} }, "paths": { - "/api/health": { + "/health": { "get": { "operationId": "health", "tags": [ @@ -2026,7 +2030,7 @@ } } }, - "/api/identity/reauth": { + "/identity/reauth": { "post": { "security": [ { @@ -2086,7 +2090,7 @@ } } }, - "/api/identity/reauth/verify": { + "/identity/reauth/verify": { "post": { "security": [ { @@ -2156,7 +2160,7 @@ } } }, - "/api/identity/guest": { + "/identity/guest": { "post": { "security": [ {}, @@ -2196,7 +2200,7 @@ } } }, - "/api/identity/session": { + "/identity/session": { "get": { "security": [ {}, @@ -2226,7 +2230,7 @@ } } }, - "/api/identity/sign-out": { + "/identity/sign-out": { "post": { "security": [ {}, @@ -2249,7 +2253,7 @@ } } }, - "/api/identity/google": { + "/identity/google": { "post": { "security": [ {}, @@ -2310,7 +2314,7 @@ } } }, - "/api/identity/google/redirect": { + "/identity/google/redirect": { "post": { "security": [ {}, @@ -2371,7 +2375,7 @@ } } }, - "/api/identity/email": { + "/identity/email": { "post": { "security": [ {}, @@ -2431,7 +2435,7 @@ } } }, - "/api/identity/email/verify": { + "/identity/email/verify": { "post": { "security": [ {}, @@ -2511,7 +2515,7 @@ } } }, - "/api/sync": { + "/sync": { "post": { "security": [ { @@ -2571,7 +2575,7 @@ } } }, - "/api/groups/{groupId}": { + "/groups/{groupId}": { "put": { "security": [ { @@ -2585,7 +2589,7 @@ "tags": [ "groups" ], - "summary": "Make a group and its creator's place in it, or rename, archive or change a setting", + "summary": "Make a group and its creator's place in it, or change shared settings", "description": "Idempotent, so a retry whose response was lost returns the same group. Editing needs membership; creating does not.", "parameters": [ { @@ -2694,7 +2698,7 @@ } } }, - "/api/groups/{groupId}/members/{memberId}": { + "/groups/{groupId}/members/{memberId}": { "put": { "security": [ { @@ -2709,7 +2713,7 @@ "groups" ], "summary": "Add somebody who has never opened the app, or change a name, a payment handle, or whether somebody is still here", - "description": "Your own row and any placeholder are editable; another account holder's are not. Leaving is always yours to do; removing somebody else requires them to be settled in every currency.", + "description": "Your own row and any placeholder are editable; another account holder's are not. Leaving or removing somebody requires them to be settled in every currency; leaving ends access. The last account holder can leave only when the entire group is settled, which permanently deletes the group.", "parameters": [ { "schema": { @@ -2828,7 +2832,7 @@ } } }, - "/api/groups/{groupId}/entries/{entryId}": { + "/groups/{groupId}/entries/{entryId}": { "put": { "security": [ { @@ -2962,7 +2966,7 @@ } } }, - "/api/groups/{groupId}/members/{memberId}/invite": { + "/groups/{groupId}/members/{memberId}/invite": { "post": { "security": [ { @@ -3086,7 +3090,7 @@ } } }, - "/api/groups/{groupId}/link": { + "/groups/{groupId}/link": { "post": { "security": [ { @@ -3419,7 +3423,7 @@ } } }, - "/api/links/{token}": { + "/links/{token}": { "get": { "security": [ {}, @@ -3483,7 +3487,7 @@ } } }, - "/api/links/{token}/placeholders": { + "/links/{token}/placeholders": { "get": { "security": [ { @@ -3596,7 +3600,7 @@ } } }, - "/api/links/{token}/join": { + "/links/{token}/join": { "post": { "security": [ { @@ -3719,7 +3723,7 @@ } } }, - "/api/profiles": { + "/profiles": { "get": { "security": [ { @@ -3789,7 +3793,7 @@ } } }, - "/api/profile": { + "/profile": { "put": { "security": [ { @@ -3848,7 +3852,7 @@ } } }, - "/api/devices": { + "/devices": { "put": { "security": [ { @@ -3908,7 +3912,7 @@ } } }, - "/api/devices/{token}": { + "/devices/{token}": { "delete": { "security": [ { @@ -3960,7 +3964,7 @@ } } }, - "/api/account": { + "/account": { "delete": { "security": [ { @@ -4010,7 +4014,7 @@ } } }, - "/api/reference": { + "/reference": { "get": { "operationId": "getReference", "tags": [ @@ -4045,7 +4049,7 @@ } } }, - "/api/fx": { + "/fx": { "get": { "operationId": "getFxRates", "tags": [ @@ -4089,7 +4093,7 @@ } } }, - "/api/fx/backfill": { + "/fx/backfill": { "post": { "security": [ { diff --git a/docs/resetting-the-backend.md b/docs/resetting-the-backend.md index f0d5ed4..a455402 100644 --- a/docs/resetting-the-backend.md +++ b/docs/resetting-the-backend.md @@ -1,219 +1,71 @@ -# Resetting the backend - -While the app is in closed testing and the rows in production are not worth -keeping, the answer to "the migrations changed shape" is to rebuild rather than -to transform. This is that procedure, and the handful of things about it that -are not obvious. - -The short version: **D1 is easy and Durable Objects are not.** D1 has a delete -command, an export, and point-in-time recovery. A Durable Object namespace has -none of those, and there is no command that empties one. Getting that wrong -leaves storage you are billed for and cannot reach. - ---- - -## Why a reset is needed at all - -Forward migrations do not need one. `wrangler d1 migrations apply` runs whatever -is new, and each group object applies its own outstanding migrations on first -open after a deploy. - -A reset is for the other case: a migration file that has *already run* turning -out to be wrong. There is no down migration on either side, and a group object -that has applied `0003` will never apply a corrected `0003`. Rebuilding is the -only honest answer, and it is cheap only while nobody's records matter. - ---- - -## 1. Take the backup you will not read - -```sh -cd server -pnpm exec wrangler d1 export opensplit --remote --output backup.sql -``` - -Insurance, not an input. Nothing below reads it; it exists so that "it turned -out to matter after all" is recoverable. - -There is **no equivalent for the Durable Objects**, which is where every ledger -actually lives. `wrangler d1 export` covers the index and the accounts and -nothing else. If a group's contents matter, pull them through the API from a -device before you start. - -D1 also has Time Travel, which is a better answer than a file for the thirty -days it covers: - -```sh -pnpm exec wrangler d1 time-travel info opensplit -pnpm exec wrangler d1 time-travel restore opensplit --timestamp -``` - ---- - -## 2. Destroy the group objects - -This is the part with a sharp edge. A Durable Object namespace is deleted by -deploying a `deleted` tombstone in `exports`, and the platform enforces that the -class is **not present in the Worker code** when you do it. So it is two -deploys, with the tree temporarily missing a class: - -```jsonc -// server/wrangler.jsonc — temporarily -"durable_objects": { - "bindings": [{ "name": "FX", "class_name": "Fx" }] // GROUP removed -}, -"exports": { - "Group": { "type": "durable-object", "state": "deleted" }, - "Fx": { "type": "durable-object", "storage": "sqlite" } -} -``` - -with `export { Group }` commented out of `src/index.ts`, then: - -```sh -pnpm exec wrangler deploy --env="" # the namespace and every group in it are gone -``` - -Then put both back as they were — `"Group": { "type": "durable-object", -"storage": "sqlite" }` — and deploy again. The second deploy provisions a fresh, -empty namespace under the same name. - -Three things worth knowing before you do it: - -- **It is not a soft delete and there is no trash.** Every group's members, - entries, payers, shares and history go at once. -- **The Worker is briefly deployed without a `GROUP` binding.** Every ledger - request 500s until the second deploy. That is fine for a reset and would not - be fine for anything else, which is the reason to do both deploys back to - back and nothing else in between. -- **Lifecycle changes cannot be part of a gradual rollout**, and a version - containing one cannot be rolled back past. Deploy it on its own. - -`pnpm exec wrangler delete` is the blunter alternative: it removes the whole Worker -and its namespaces in one command. It also removes the secrets and detaches the -custom domain, so the way back is most of the runbook. Prefer the tombstone. - ---- - -## 3. Rebuild D1 - -```sh -pnpm exec wrangler d1 delete opensplit -pnpm exec wrangler d1 create opensplit # prints a NEW database_id -``` - -Put the new id in `server/wrangler.jsonc` and commit it, then: - -```sh -pnpm exec wrangler d1 migrations apply opensplit --remote -pnpm exec wrangler deploy --env="" -``` - -Recreating rather than dropping tables by hand, because the migration state -lives in a table of its own and a partial drop leaves `d1_migrations` claiming -work that is no longer there. - -### The trap: resetting D1 alone - -D1 is an index, not the truth. Wipe it while the group objects survive and every -group still exists, still holds its ledger, and is still billed for its storage -— but nothing can name it. `memberships` is how a device discovers which groups -it is in, and account deletion reads that same table to decide which objects to -visit, so it cannot find them either. - -Nothing reports this. It looks like every account came back empty. - -So: **objects first, then D1** — or neither. - ---- - -## 4. Put back what migrations cannot carry - -Secrets survive a D1 reset, because they belong to the Worker. They do **not** -survive `wrangler delete`. After one of those, all of step 6 ("Secrets") in -[the runbook](runbook.md) again. - -KV needs nothing. The rate blobs and the FCM token rebuild themselves — the -token on the next push, the rates on the next 04:00 UTC run or whenever you ask: - -```sh -curl -s -X POST https://opensplit.eigeninteractive.com/api/fx/backfill \ - -H 'content-type: application/json' \ - -d "{\"asOf\":\"$(date -u -v-3d +%F)\",\"currency\":\"EUR\"}" -``` - -### Check it came back - -Most of this is automatic. These are the three that fail quietly: - -```sh -base=https://opensplit.eigeninteractive.com - -# Reference data ships inside the Worker script, so this is really a check -# that the deploy is the build you think it is. -curl -s $base/api/reference | head -c 120 - -# D1 is bound and writable. A guest sign-in writes a user and a session. -curl -s -X POST $base/api/identity/guest - -# A group object can be created and reached at all. -pnpm exec wrangler tail --format pretty -``` - ---- - -## It does not reach the devices - -A tester's app holds its own copy of every group and expense and renders -entirely from it. Wiping the server does not wipe the phones, and the app goes -on showing groups the server has never heard of — indefinitely, because nothing -treats "the server does not have this" as "delete it", and nothing should: that -is the same signal a permissions problem gives. - -Their queued writes then reference ids that no longer exist. The server refuses -those permanently rather than transiently — a refusal carries its own kind, and -a permanent one is set aside instead of retrying — so every such change lands -in the "could not be saved" banner. Discarding there clears them, but it is one -tester at a time. - -So a reset needs the devices cleared too: **clear app storage, or uninstall and -reinstall.** - -Unless the same release bumps `AppDatabase.schemaVersion`, in which case it is -already handled — the upgrade path is drift's `destructiveFallback`, which drops -every local table, so installing the update *is* clearing app storage. See -[local-database.md](local-database.md). - ---- - -## Order, when a client release goes with it - -**Server first.** An updated client has already wiped its local database and -repopulates entirely from the server, so it is not a client with stale data, it -is an empty one until the server it pulls from is the new one. An old client -against a new server merely fails to sync, which is what you want — it is -displaying a history that no longer exists. - -Two things about how the client actually ships: - -- **Merging to `main` releases.** `release.yml` runs on every push to `main` and - its deploy steps are gated on `github.event_name == 'push' || inputs.deploy`, - so a merge deploys the Worker and the web bundle together and uploads to Play - closed testing with no further action. Reset before merging, not after. -- **A merge cannot set the update priority from the workflow input.** - `PLAY_UPDATE_PRIORITY` resolves as - `inputs.update_priority || vars.PLAY_UPDATE_PRIORITY || '0'`, and on a push - the input is empty, so only the variable decides. It lives on the `production` - environment rather than at repository level, which matters because a - repository-level variable of the same name is shadowed by it and would look - set while doing nothing: - - ```bash - gh variable list -e production - gh variable set PLAY_UPDATE_PRIORITY -e production -b 4 - ``` - - Priority is settable only through the Publishing API, at upload time, on the - release it ships with — a build that needed a blocking update and did not get - one needs another build. The variable persists, so put it back to 0 once the - release that needed it is out, or every subsequent merge interrupts testers. +# Resetting development storage + +Use resets only for development data whose loss is acceptable, after backing up +anything that must survive. Normal releases use incremental D1, Durable Object +and Drift migrations. Resetting storage is separate manual maintenance; temporary +deployment configurations, exports and restore scripts stay outside Git. + +## Back up first + +Export the required D1 account, profile and membership rows and the related +Durable Object ledger rows. Preserve identities, references, financial history, +recorded exchange rates and sequence counters together. The app's JSON export +does not include all server authentication and discovery data. + +Rehearse the restore against fresh schemas and verify references, counts and +balances before deleting anything. Keep the private backup outside the checkout. +Pause application use while replacing storage so writes cannot land between +the backup and restoration. + +## Durable Objects + +Removing a binding or class export alone is not a complete namespace reset. +Cloudflare's declarative `exports` configuration manages namespace lifecycle. +In a temporary local deployment: + +1. Remove the affected classes from the Worker's code exports. +2. Remove their `durable_objects.bindings` entries. +3. Replace their live `exports` entries with + `{ "type": "durable-object", "state": "deleted" }`. +4. From `server/`, run `pnpm exec wrangler deploy --env=""` against the intended + account. A temporary maintenance entrypoint can return 503 while storage is + unavailable, without shipping product handlers that expect those bindings. + +This permanently deletes each selected namespace and all of its objects. It has +no trash. Restore the normal code, bindings and live `exports` configuration +before the next product deployment; Cloudflare provisions fresh namespaces. +An object's logical name can remain the same, but its physical ID changes. +See [Cloudflare's class deletion procedure](https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/#delete-a-durable-object-class). + +The Worker, domain and secrets do not need to be deleted. Reset only the storage +that needs replacing; rebuilding group storage does not itself require resetting +the FX namespace or D1. + +## D1 and restoration + +D1 holds authentication, profiles and group discovery separately from each +group's financial database. A Durable Object reset leaves D1 untouched; clear or +rebuild stale discovery rows as part of the same maintenance operation. + +If recreating D1 is necessary, record the new database ID in `wrangler.jsonc` +before releasing, and apply the committed D1 migrations to the fresh database. +Initialize fresh group objects using the product schema, then restore the +reviewed rows. Verify authentication, group discovery, full synchronization, +foreign keys and balances before resuming use. + +Pushes to `main` deploy automatically after CI. Finish any required reset and +prepare restoration before merging; do not merge an initial-schema replacement +onto old databases. Restore all temporary local code/configuration edits before +committing the product changes. + +## Device storage + +Server storage and device databases are independent. A server reset does not +erase a device's cache or queued edits. Unsupported API paths must be refused +before reaching data handlers so stale clients cannot repopulate reset storage. + +The app uses `opensplit-v1-` at local schema version 1 and leaves its +earlier database files unused. Future local schema upgrades keep this filename +and migrate data and pending edits. Do not use a server reset as a substitute for +those migrations. See [the local database](local-database.md) and +[API retirement](api-upgrades.md). diff --git a/docs/runbook.md b/docs/runbook.md index fc98920..39b8283 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -6,8 +6,8 @@ release after that does. Commands that change something in a Cloudflare, Google, Resend, GitHub or Play account are in the production sections, and only there. Nothing in the -repository runs them for you, apart from the release workflow on a push to -`main`. +repository runs them for you, apart from an explicitly dispatched **Release** +workflow with **deploy** checked. Pushes to `main` build artifacts only. --- @@ -202,11 +202,11 @@ is made, so a lost one is replaced by *Add secret* on the client, put in with authorized redirect URI, and the origin: ``` -https://opensplit.eigeninteractive.com/api/auth/callback/google +https://opensplit.eigeninteractive.com/api/v1/auth/callback/google https://opensplit.eigeninteractive.com (JavaScript origin) ``` -The path is Better Auth's (`basePath: "/api/auth"` in `server/src/auth.ts`) and +The path uses `authBasePath`, derived from `apiBasePath` in `server/src/paths.ts`, and must match character for character, with no trailing slash. A wrong redirect URI fails at Google's consent screen with `redirect_uri_mismatch`, before the request reaches the Worker, so there is no trace of it in our logs. @@ -338,10 +338,12 @@ A push to `main` runs `.github/workflows/release.yml`: 8. A `v` tag, the first time a build of that version ships. D1 migrations are applied before the Worker that needs them goes live. -Before launch, a schema change needs no compatibility period; if one is -awkward, resetting the backend is simpler (`docs/resetting-the-backend.md`). The Durable Objects migrate themselves, lazily, the first time each group is -opened after a deploy. +opened after a deploy. A breaking API requires a coordinated pause that covers +Play review and installation, because old clients remain installed after the +backend changes. Arrange any required [storage reset](resetting-the-backend.md) +before merging a release that needs one. Normal schema changes use incremental +migrations; an initial schema must not be reapplied over existing databases. A manual run (*Actions → Release → Run workflow*) with **deploy** unchecked builds the artifacts without deploying anything. @@ -418,8 +420,9 @@ Both files go in one pull request. A bump without the release leaves Which part to bump: patch when a version only fixes things, minor when it adds anything, and major when it leaves older clients behind, such as a server change -they can no longer talk to. A major one is also the release to send with update -priority 4, so those clients are made to update. +they can no longer talk to. Send a breaking release with update priority 4 so +eligible clients request an immediate update when Play makes it available. +Priority does not replace the pause or verify that everyone has installed it. Merges between versions keep the version's name and differ by build number; "1.2.0 (1701)" and "1.2.0 (1804)" are two builds of 1.2.0. @@ -435,7 +438,9 @@ rest on a last line. The notes are in `en-US`, the store listing's language, because Play refuses notes in a language the listing lacks. Add a language to the listing, and the workflow has to write that language too. -**Play's artwork.** `tool/play_metadata.dart` packages the notes and the current +**Play's artwork.** First run `dart run tool/store_artifacts.dart` to refresh +screenshots, the feature graphic and their derivatives together. CI runs its +`--check` mode and rejects stale sources or outputs. `tool/play_metadata.dart` packages the notes and the current committed artwork into a fresh `en-US` supply tree. It checks all inputs before writing, resizes the icon to 512 × 512 and retains the feature graphic and screenshots byte-for-byte. The workflow passes that directory as @@ -474,6 +479,7 @@ step. ```sh base=https://opensplit.eigeninteractive.com +api_base="$base/api/v1" # current contract base; server/src/paths.ts owns it curl -s $base/api/health # {"ok":true,"service":"opensplit",...} curl -sI $base/ # 200, the landing page @@ -481,14 +487,14 @@ curl -sI $base/privacy # 200, not a redirect curl -sI $base/app/ | grep -i cross-origin # both isolation headers curl -sI $base/app/g/does-not-exist-yet # 200 and text/html: the shell curl -sI $base/.well-known/assetlinks.json # 200 application/json, no redirect -curl -s "$base/api/reference" | head -c 200 # currencies and categories +curl -s "$api_base/reference" | head -c 200 # currencies and categories ``` `/api/health` proves the script deployed, not that D1 is bound, since it touches no database. A guest sign-in does, writing a user and a session: ```sh -token=$(curl -s -X POST $base/api/identity/guest | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') +token=$(curl -s -X POST $api_base/identity/guest | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') echo "$token" # non-empty: D1 and BETTER_AUTH_SECRET work ``` @@ -502,11 +508,11 @@ reuse the guest's: ```sh pnpm exec wrangler tail --format pretty & -curl -s -X POST $base/api/fx/backfill -H "Authorization: Bearer $token" \ +curl -s -X POST $api_base/fx/backfill -H "Authorization: Bearer $token" \ -H 'content-type: application/json' \ -d "{\"asOf\":\"$(date -u -v-3d +%F)\",\"currency\":\"EUR\"}" # {"accepted":true} -curl -s "$base/api/fx?since=$(date -u -v-7d +%F)" | head -c 300 +curl -s "$api_base/fx?since=$(date -u -v-7d +%F)" | head -c 300 ``` In the tail, `[fx] stored N months …` means rates reached KV. `[fx] uncovered @@ -543,6 +549,13 @@ permission as well as access to release on testing tracks, so Fastlane can update the listing artwork. The upload does not change listing text or promote the bundle to production. Play review and availability still apply. +Verify the feature graphic under **Grow users → Store presence → Main store +listing → Graphics** after uploading and publishing. Its presence there is +separate from its visibility on a particular public listing: Google uses it as +the preview-video cover and on selected promotional surfaces. A missing public +banner alone does not establish an upload failure. See +[Google's preview asset guidance](https://support.google.com/googleplay/android-developer/answer/9866151). + Under *App content*, the three URLs, each a real static file answering 200 with no redirect, and the same constants the app's Settings screen opens (`lib/config.dart`): diff --git a/docs/ui-ux.md b/docs/ui-ux.md index 442fbd9..ae2c070 100644 --- a/docs/ui-ux.md +++ b/docs/ui-ux.md @@ -13,7 +13,7 @@ should help people verify those decisions before recording them. | Journey | Presentation and purpose | | --- | --- | | Start, sign in, save a guest account | A clear primary action, ordinary filled fields, and outlined alternative identity actions. Recovery is available from Account and Settings. | -| Groups and archive | Group identity, name, and balance remain scannable. Standard FABs create groups; navigation becomes a drawer or rail as space permits. | +| Groups | Group identity, name, and balance remain scannable. Standard FABs create groups; navigation becomes a drawer or rail as space permits. | | Group journal | A tonal cover identifies the group. Expenses are grouped by day; amounts and payer summaries remain distinct. Expenses and balances use stock tabs, with both panels visible on wide screens. | | Add or edit expense | Amount and currency form the headline. Description, category, and day follow. Separate payer and split cards group the two money decisions. Optional time is in a stock expansion tile. | | People and invitations | Member avatars use the active palette by default. Membership actions, placeholders, and invite sheets retain standard lists, buttons, menus, and sheets. | @@ -150,8 +150,9 @@ opt-out, and a changed palette on resume. The actual app is built with `dart run tool/build_web.dart` and served through `cd server && pnpm dev` on the same localhost origin. This preserves the real Worker, OPFS database, session, and headers. The committed product screenshots -are rendered by `flutter test tool/screenshots_test.dart` from repository demo -data. Browser verification and native-device limits are reported with the +are refreshed with `dart run tool/store_artifacts.dart` from repository demo +data, together with the feature graphic and other derivatives. CI rejects stale +source or output hashes. Browser verification and native-device limits are reported with the change; automated tests do not replace TalkBack or physical One UI acceptance. Recorded validation for this change: diff --git a/drift_schemas/drift_schema_v1.json b/drift_schemas/drift_schema_v1.json index d602a28..790859c 100644 --- a/drift_schemas/drift_schema_v1.json +++ b/drift_schemas/drift_schema_v1.json @@ -4,7 +4,7 @@ "version": "1.3.0" }, "options": { - "store_date_time_values_as_text": false + "store_date_time_values_as_text": true }, "entities": [ { @@ -73,35 +73,45 @@ }, { "id": 1, - "references": [], + "references": [ + 0 + ], "type": "table", "data": { - "name": "profiles", + "name": "groups", "was_declared_in_moor": false, "columns": [ { - "name": "id", - "getter_name": "id", + "name": "avatar_kind", + "getter_name": "avatarKind", "moor_type": "string", "nullable": false, "customConstraints": null, - "default_dart": null, + "default_dart": "const CustomExpression('\\'initials\\'')", "default_client_dart": null, - "dsl_features": [] + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(AvatarKind.values,AvatarKind.unknownDefaultOpenApi)", + "dart_type_name": "AvatarKind" + } }, { - "name": "display_name", - "getter_name": "displayName", + "name": "avatar_color", + "getter_name": "avatarColor", "moor_type": "string", "nullable": true, "customConstraints": null, "default_dart": null, "default_client_dart": null, - "dsl_features": [] + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(AvatarColor.values,AvatarColor.unknownDefaultOpenApi)", + "dart_type_name": "AvatarColor" + } }, { - "name": "avatar_url", - "getter_name": "avatarUrl", + "name": "avatar_emoji", + "getter_name": "avatarEmoji", "moor_type": "string", "nullable": true, "customConstraints": null, @@ -110,44 +120,29 @@ "dsl_features": [] }, { - "name": "upi_vpa", - "getter_name": "upiVpa", + "name": "avatar_icon", + "getter_name": "avatarIcon", "moor_type": "string", "nullable": true, "customConstraints": null, "default_dart": null, "default_client_dart": null, - "dsl_features": [] + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(AvatarIcon.values,AvatarIcon.unknownDefaultOpenApi)", + "dart_type_name": "AvatarIcon" + } }, { - "name": "updated_at", - "getter_name": "updatedAt", - "moor_type": "dateTime", + "name": "avatar_photo", + "getter_name": "avatarPhoto", + "moor_type": "string", "nullable": true, "customConstraints": null, "default_dart": null, "default_client_dart": null, "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 2, - "references": [ - 0 - ], - "type": "table", - "data": { - "name": "groups", - "was_declared_in_moor": false, - "columns": [ + }, { "name": "id", "getter_name": "id", @@ -223,134 +218,23 @@ "dsl_features": [] }, { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "archived_at", - "getter_name": "archivedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "updated_at", - "getter_name": "updatedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')", - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 3, - "references": [ - 2 - ], - "type": "table", - "data": { - "name": "members", - "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": "group_id", - "getter_name": "groupId", + "name": "cover_kind", + "getter_name": "coverKind", "moor_type": "string", "nullable": false, "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "profile_id", - "getter_name": "profileId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, + "default_dart": "const CustomExpression('\\'generated\\'')", "default_client_dart": null, - "dsl_features": [] + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(CoverKind.values,CoverKind.unknownDefaultOpenApi)", + "dart_type_name": "CoverKind" + } }, { - "name": "display_name", - "getter_name": "displayName", + "name": "cover_photo", + "getter_name": "coverPhoto", "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "joined_at", - "getter_name": "joinedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "left_at", - "getter_name": "leftAt", - "moor_type": "dateTime", "nullable": true, "customConstraints": null, "default_dart": null, @@ -358,8 +242,8 @@ "dsl_features": [] }, { - "name": "upi_vpa", - "getter_name": "upiVpa", + "name": "created_by", + "getter_name": "createdBy", "moor_type": "string", "nullable": true, "customConstraints": null, @@ -368,57 +252,20 @@ "dsl_features": [] }, { - "name": "updated_at", - "getter_name": "updatedAt", + "name": "created_at", + "getter_name": "createdAt", "moor_type": "dateTime", "nullable": false, "customConstraints": null, - "default_dart": "const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')", - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 4, - "references": [], - "type": "table", - "data": { - "name": "categories", - "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": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, "default_dart": null, "default_client_dart": null, "dsl_features": [] }, { - "name": "icon", - "getter_name": "icon", - "moor_type": "string", - "nullable": false, + "name": "seq", + "getter_name": "seq", + "moor_type": "int", + "nullable": true, "customConstraints": null, "default_dart": null, "default_client_dart": null, @@ -434,9 +281,9 @@ } }, { - "id": 5, + "id": 2, "references": [ - 2 + 1 ], "type": "table", "data": { @@ -489,7 +336,7 @@ "default_client_dart": null, "dsl_features": [], "type_converter": { - "dart_expr": "const EnumNameConverter(EntryKind.values)", + "dart_expr": "const WireEnumConverter(EntryKind.values,EntryKind.unknownDefaultOpenApi)", "dart_type_name": "EntryKind" } }, @@ -536,11 +383,35 @@ { "name": "entry_date", "getter_name": "entryDate", - "moor_type": "dateTime", + "moor_type": "string", "nullable": false, "customConstraints": null, "default_dart": null, "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const CalendarDateConverter()", + "dart_type_name": "DateTime" + } + }, + { + "name": "occurred_at", + "getter_name": "occurredAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "time_zone", + "getter_name": "timeZone", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, "dsl_features": [] }, { @@ -553,7 +424,7 @@ "default_client_dart": null, "dsl_features": [], "type_converter": { - "dart_expr": "const EnumNameConverter(SplitKind.values)", + "dart_expr": "const WireEnumConverter(SplitKind.values,SplitKind.unknownDefaultOpenApi)", "dart_type_name": "SplitKind" } }, @@ -618,19 +489,9 @@ "dsl_features": [] }, { - "name": "updated_at", - "getter_name": "updatedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "base_updated_at", - "getter_name": "baseUpdatedAt", - "moor_type": "dateTime", + "name": "seq", + "getter_name": "seq", + "moor_type": "int", "nullable": true, "customConstraints": null, "default_dart": null, @@ -646,16 +507,6 @@ "default_dart": null, "default_client_dart": null, "dsl_features": [] - }, - { - "name": "client_key", - "getter_name": "clientKey", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] } ], "is_virtual": false, @@ -666,11 +517,380 @@ ] } }, + { + "id": 3, + "references": [ + 2 + ], + "type": "table", + "data": { + "name": "entries_fts", + "was_declared_in_moor": true, + "columns": [ + { + "name": "description", + "getter_name": "description", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": true, + "create_virtual_stmt": "CREATE VIRTUAL TABLE \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid)", + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 4, + "references": [ + 2, + 3 + ], + "type": "trigger", + "data": { + "on": 2, + "references_in_body": [ + 2, + 3 + ], + "name": "entries_fts_insert", + "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" + } + }, + { + "id": 5, + "references": [ + 2, + 3 + ], + "type": "trigger", + "data": { + "on": 2, + "references_in_body": [ + 2, + 3 + ], + "name": "entries_fts_delete", + "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\nEND;" + } + }, { "id": 6, "references": [ - 5, - 3 + 2, + 3 + ], + "type": "trigger", + "data": { + "on": 2, + "references_in_body": [ + 2, + 3 + ], + "name": "entries_fts_update", + "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" + } + }, + { + "id": 7, + "references": [], + "type": "table", + "data": { + "name": "profiles", + "was_declared_in_moor": false, + "columns": [ + { + "name": "avatar_kind", + "getter_name": "avatarKind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'initials\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(AvatarKind.values,AvatarKind.unknownDefaultOpenApi)", + "dart_type_name": "AvatarKind" + } + }, + { + "name": "avatar_color", + "getter_name": "avatarColor", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(AvatarColor.values,AvatarColor.unknownDefaultOpenApi)", + "dart_type_name": "AvatarColor" + } + }, + { + "name": "avatar_emoji", + "getter_name": "avatarEmoji", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "avatar_icon", + "getter_name": "avatarIcon", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(AvatarIcon.values,AvatarIcon.unknownDefaultOpenApi)", + "dart_type_name": "AvatarIcon" + } + }, + { + "name": "avatar_photo", + "getter_name": "avatarPhoto", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "display_name", + "getter_name": "displayName", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "upi_vpa", + "getter_name": "upiVpa", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "version", + "getter_name": "version", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 8, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "members", + "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": "group_id", + "getter_name": "groupId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "groups", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "profile_id", + "getter_name": "profileId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "display_name", + "getter_name": "displayName", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "joined_at", + "getter_name": "joinedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "left_at", + "getter_name": "leftAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "upi_vpa", + "getter_name": "upiVpa", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "seq", + "getter_name": "seq", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 9, + "references": [], + "type": "table", + "data": { + "name": "categories", + "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": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "icon", + "getter_name": "icon", + "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": [ + "id" + ] + } + }, + { + "id": 10, + "references": [ + 2, + 8 ], "type": "table", "data": { @@ -750,10 +970,10 @@ } }, { - "id": 7, + "id": 11, "references": [ - 5, - 3 + 2, + 8 ], "type": "table", "data": { @@ -843,15 +1063,14 @@ } }, { - "id": 8, + "id": 12, "references": [ - 5, - 2, - 3 + 1, + 8 ], "type": "table", "data": { - "name": "entry_snapshots", + "name": "group_events", "was_declared_in_moor": false, "columns": [ { @@ -864,32 +1083,6 @@ "default_client_dart": null, "dsl_features": [] }, - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, { "name": "group_id", "getter_name": "groupId", @@ -953,104 +1146,100 @@ "dsl_features": [] }, { - "name": "description", - "getter_name": "description", + "name": "kind", + "getter_name": "kind", "moor_type": "string", "nullable": false, "customConstraints": null, "default_dart": null, "default_client_dart": null, - "dsl_features": [] + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(EventKind.values,EventKind.unknownDefaultOpenApi)", + "dart_type_name": "EventKind" + } }, { - "name": "currency", - "getter_name": "currency", + "name": "subject_id", + "getter_name": "subjectId", "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "entry_date", - "getter_name": "entryDate", - "moor_type": "dateTime", - "nullable": false, + "nullable": true, "customConstraints": null, "default_dart": null, "default_client_dart": null, "dsl_features": [] }, { - "name": "split_kind", - "getter_name": "splitKind", + "name": "entry", + "getter_name": "entry", "moor_type": "string", - "nullable": false, + "nullable": true, "customConstraints": null, "default_dart": null, "default_client_dart": null, "dsl_features": [], "type_converter": { - "dart_expr": "const EnumNameConverter(SplitKind.values)", - "dart_type_name": "SplitKind" + "dart_expr": "const WireJsonConverter(EntrySnapshot.fromJson)", + "dart_type_name": "EntrySnapshot" } }, { - "name": "category_id", - "getter_name": "categoryId", + "name": "member", + "getter_name": "member", "moor_type": "string", "nullable": true, "customConstraints": null, "default_dart": null, "default_client_dart": null, - "dsl_features": [] + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireJsonConverter(MemberEventPayload.fromJson)", + "dart_type_name": "MemberEventPayload" + } }, { - "name": "notes", - "getter_name": "notes", + "name": "group", + "getter_name": "group", "moor_type": "string", "nullable": true, "customConstraints": null, "default_dart": null, "default_client_dart": null, - "dsl_features": [] + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireJsonConverter(GroupEventPayload.fromJson)", + "dart_type_name": "GroupEventPayload" + } }, { - "name": "deleted_at", - "getter_name": "deletedAt", - "moor_type": "dateTime", + "name": "link", + "getter_name": "link", + "moor_type": "string", "nullable": true, "customConstraints": null, "default_dart": null, "default_client_dart": null, - "dsl_features": [] + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireJsonConverter(LinkEventPayload.fromJson)", + "dart_type_name": "LinkEventPayload" + } }, { - "name": "payers", - "getter_name": "payers", - "moor_type": "string", - "nullable": false, + "name": "seq", + "getter_name": "seq", + "moor_type": "int", + "nullable": true, "customConstraints": null, "default_dart": null, "default_client_dart": null, "dsl_features": [] }, { - "name": "shares", - "getter_name": "shares", - "moor_type": "string", - "nullable": false, + "name": "ordinal", + "getter_name": "ordinal", + "moor_type": "int", + "nullable": true, "customConstraints": null, "default_dart": null, "default_client_dart": null, @@ -1080,10 +1269,10 @@ } }, { - "id": 9, + "id": 13, "references": [ - 5, - 2 + 2, + 1 ], "type": "table", "data": { @@ -1150,6 +1339,20 @@ "customConstraints": null, "default_dart": null, "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireJsonConverter(EntrySnapshot.fromJson)", + "dart_type_name": "EntrySnapshot" + } + }, + { + "name": "base_seq", + "getter_name": "baseSeq", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, "dsl_features": [] }, { @@ -1172,7 +1375,7 @@ } }, { - "id": 10, + "id": 14, "references": [], "type": "table", "data": { @@ -1230,7 +1433,7 @@ } }, { - "id": 11, + "id": 15, "references": [], "type": "table", "data": { @@ -1238,18 +1441,22 @@ "was_declared_in_moor": false, "columns": [ { - "name": "id", - "getter_name": "id", + "name": "target", + "getter_name": "target", "moor_type": "string", "nullable": false, "customConstraints": null, "default_dart": null, "default_client_dart": null, - "dsl_features": [] + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(OutboxTarget.values)", + "dart_type_name": "OutboxTarget" + } }, { - "name": "operation", - "getter_name": "operation", + "name": "target_id", + "getter_name": "targetId", "moor_type": "string", "nullable": false, "customConstraints": null, @@ -1258,8 +1465,8 @@ "dsl_features": [] }, { - "name": "target_id", - "getter_name": "targetId", + "name": "revision", + "getter_name": "revision", "moor_type": "string", "nullable": false, "customConstraints": null, @@ -1268,68 +1475,124 @@ "dsl_features": [] }, { - "name": "revision", - "getter_name": "revision", - "moor_type": "string", + "name": "position", + "getter_name": "position", + "moor_type": "int", "nullable": false, "customConstraints": null, - "default_dart": "const CustomExpression('\\'\\'')", + "default_dart": null, "default_client_dart": null, "dsl_features": [] }, { - "name": "payload", - "getter_name": "payload", - "moor_type": "string", + "name": "attempts", + "getter_name": "attempts", + "moor_type": "int", "nullable": false, "customConstraints": null, - "default_dart": "const CustomExpression('\\'{}\\'')", + "default_dart": "const CustomExpression('0')", "default_client_dart": null, "dsl_features": [] }, { - "name": "created_at", - "getter_name": "createdAt", + "name": "next_attempt_at", + "getter_name": "nextAttemptAt", "moor_type": "dateTime", - "nullable": false, + "nullable": true, "customConstraints": null, "default_dart": null, "default_client_dart": null, "dsl_features": [] }, { - "name": "attempts", - "getter_name": "attempts", - "moor_type": "int", - "nullable": false, + "name": "last_error", + "getter_name": "lastError", + "moor_type": "string", + "nullable": true, "customConstraints": null, - "default_dart": "const CustomExpression('0')", + "default_dart": null, "default_client_dart": null, "dsl_features": [] }, { - "name": "next_attempt_at", - "getter_name": "nextAttemptAt", + "name": "failure_code", + "getter_name": "failureCode", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "dead_lettered_at", + "getter_name": "deadLetteredAt", "moor_type": "dateTime", "nullable": true, "customConstraints": null, "default_dart": null, "default_client_dart": null, "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "target", + "target_id" + ] + } + }, + { + "id": 16, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "group_cursors", + "was_declared_in_moor": false, + "columns": [ + { + "name": "group_id", + "getter_name": "groupId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "groups", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] }, { - "name": "last_error", - "getter_name": "lastError", - "moor_type": "string", - "nullable": true, + "name": "seq", + "getter_name": "seq", + "moor_type": "int", + "nullable": false, "customConstraints": null, - "default_dart": null, + "default_dart": "const CustomExpression('0')", "default_client_dart": null, "dsl_features": [] }, { - "name": "dead_lettered_at", - "getter_name": "deadLetteredAt", + "name": "last_synced_at", + "getter_name": "lastSyncedAt", "moor_type": "dateTime", "nullable": true, "customConstraints": null, @@ -1342,16 +1605,16 @@ "without_rowid": false, "constraints": [], "explicit_pk": [ - "id" + "group_id" ] } }, { - "id": 12, + "id": 17, "references": [], "type": "table", "data": { - "name": "sync_cursors", + "name": "feed_cursors", "was_declared_in_moor": false, "columns": [ { @@ -1367,16 +1630,6 @@ { "name": "cursor", "getter_name": "cursor", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "cursor_id", - "getter_name": "cursorId", "moor_type": "string", "nullable": true, "customConstraints": null, @@ -1404,7 +1657,7 @@ } }, { - "id": 13, + "id": 18, "references": [], "type": "table", "data": { @@ -1451,7 +1704,7 @@ } }, { - "id": 14, + "id": 19, "references": [], "type": "table", "data": { @@ -1500,6 +1753,212 @@ "id" ] } + }, + { + "id": 20, + "references": [ + 12 + ], + "type": "index", + "data": { + "on": 12, + "name": "group_events_group", + "sql": null, + "unique": false, + "columns": [ + { + "column": "group_id", + "order_by": null + }, + { + "column": "seq", + "order_by": null + }, + { + "column": "ordinal", + "order_by": null + } + ] + } + }, + { + "id": 21, + "references": [ + 12 + ], + "type": "index", + "data": { + "on": 12, + "name": "group_events_subject", + "sql": null, + "unique": false, + "columns": [ + { + "column": "subject_id", + "order_by": null + } + ] + } + }, + { + "id": 22, + "references": [ + 12 + ], + "type": "index", + "data": { + "on": 12, + "name": "group_events_actor", + "sql": null, + "unique": false, + "columns": [ + { + "column": "actor_id", + "order_by": null + } + ] + } + }, + { + "id": 23, + "references": [ + 8 + ], + "type": "index", + "data": { + "on": 8, + "name": "members_group", + "sql": null, + "unique": false, + "columns": [ + { + "column": "group_id", + "order_by": null + } + ] + } + }, + { + "id": 24, + "references": [ + 8 + ], + "type": "index", + "data": { + "on": 8, + "name": "members_profile", + "sql": null, + "unique": false, + "columns": [ + { + "column": "profile_id", + "order_by": null + } + ] + } + }, + { + "id": 25, + "references": [ + 2 + ], + "type": "index", + "data": { + "on": 2, + "name": "entries_group", + "sql": null, + "unique": false, + "columns": [ + { + "column": "group_id", + "order_by": null + }, + { + "column": "entry_date", + "order_by": null + } + ] + } + }, + { + "id": 26, + "references": [ + 13 + ], + "type": "index", + "data": { + "on": 13, + "name": "entry_conflicts_group", + "sql": null, + "unique": false, + "columns": [ + { + "column": "group_id", + "order_by": null + } + ] + } + }, + { + "id": 27, + "references": [ + 10 + ], + "type": "index", + "data": { + "on": 10, + "name": "entry_payers_member", + "sql": null, + "unique": false, + "columns": [ + { + "column": "member_id", + "order_by": null + } + ] + } + }, + { + "id": 28, + "references": [ + 11 + ], + "type": "index", + "data": { + "on": 11, + "name": "entry_shares_member", + "sql": null, + "unique": false, + "columns": [ + { + "column": "member_id", + "order_by": null + } + ] + } + }, + { + "id": 29, + "references": [ + 14 + ], + "type": "index", + "data": { + "on": 14, + "name": "fx_rates_currency", + "sql": null, + "unique": false, + "columns": [ + { + "column": "currency", + "order_by": null + }, + { + "column": "as_of", + "order_by": null + } + ] + } } ], "fixed_sql": [ @@ -1513,47 +1972,83 @@ ] }, { - "name": "profiles", + "name": "groups", "sql": [ { "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"profiles\" (\"id\" TEXT NOT NULL, \"display_name\" TEXT NULL, \"avatar_url\" TEXT NULL, \"upi_vpa\" TEXT NULL, \"updated_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" + "sql": "CREATE TABLE IF NOT EXISTS \"groups\" (\"avatar_kind\" TEXT NOT NULL DEFAULT 'initials', \"avatar_color\" TEXT NULL, \"avatar_emoji\" TEXT NULL, \"avatar_icon\" TEXT NULL, \"avatar_photo\" TEXT NULL, \"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"default_currency\" TEXT NOT NULL REFERENCES currencies (code), \"is_direct\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_direct\" IN (0, 1)), \"simplify_debts\" INTEGER NOT NULL DEFAULT 1 CHECK (\"simplify_debts\" IN (0, 1)), \"cover_kind\" TEXT NOT NULL DEFAULT 'generated', \"cover_photo\" TEXT NULL, \"created_by\" TEXT NULL, \"created_at\" TEXT NOT NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" } ] }, { - "name": "groups", + "name": "entries", "sql": [ { "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"groups\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"default_currency\" TEXT NOT NULL REFERENCES currencies (code), \"is_direct\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_direct\" IN (0, 1)), \"simplify_debts\" INTEGER NOT NULL DEFAULT 1 CHECK (\"simplify_debts\" IN (0, 1)), \"created_by\" TEXT NULL, \"created_at\" INTEGER NOT NULL, \"archived_at\" INTEGER NULL, \"updated_at\" INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)), PRIMARY KEY (\"id\"));" + "sql": "CREATE TABLE IF NOT EXISTS \"entries\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"kind\" TEXT NOT NULL, \"description\" TEXT NOT NULL DEFAULT '', \"category_id\" TEXT NULL, \"currency\" TEXT NOT NULL, \"amount_minor\" INTEGER NOT NULL, \"entry_date\" TEXT NOT NULL, \"occurred_at\" TEXT NULL, \"time_zone\" TEXT NULL, \"split_kind\" TEXT NOT NULL, \"fx_rate\" REAL NULL, \"fx_source\" TEXT NULL, \"fx_at\" TEXT NULL, \"notes\" TEXT NULL, \"created_by\" TEXT NOT NULL, \"created_at\" TEXT NOT NULL, \"seq\" INTEGER NULL, \"deleted_at\" TEXT NULL, PRIMARY KEY (\"id\"));" } ] }, { - "name": "members", + "name": "entries_fts", "sql": [ { "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"members\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"profile_id\" TEXT NULL, \"display_name\" TEXT NOT NULL, \"joined_at\" INTEGER NOT NULL, \"left_at\" INTEGER NULL, \"upi_vpa\" TEXT NULL, \"updated_at\" INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)), PRIMARY KEY (\"id\"));" + "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid);" } ] }, { - "name": "categories", + "name": "entries_fts_insert", "sql": [ { "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"categories\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"icon\" TEXT NOT NULL, PRIMARY KEY (\"id\"));" + "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" } ] }, { - "name": "entries", + "name": "entries_fts_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);END" + } + ] + }, + { + "name": "entries_fts_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" + } + ] + }, + { + "name": "profiles", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"profiles\" (\"avatar_kind\" TEXT NOT NULL DEFAULT 'initials', \"avatar_color\" TEXT NULL, \"avatar_emoji\" TEXT NULL, \"avatar_icon\" TEXT NULL, \"avatar_photo\" TEXT NULL, \"id\" TEXT NOT NULL, \"display_name\" TEXT NULL, \"upi_vpa\" TEXT NULL, \"version\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "members", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"members\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"profile_id\" TEXT NULL, \"display_name\" TEXT NOT NULL, \"joined_at\" TEXT NOT NULL, \"left_at\" TEXT NULL, \"upi_vpa\" TEXT NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "categories", "sql": [ { "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entries\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"kind\" TEXT NOT NULL, \"description\" TEXT NOT NULL DEFAULT '', \"category_id\" TEXT NULL, \"currency\" TEXT NOT NULL, \"amount_minor\" INTEGER NOT NULL, \"entry_date\" INTEGER NOT NULL, \"split_kind\" TEXT NOT NULL, \"fx_rate\" REAL NULL, \"fx_source\" TEXT NULL, \"fx_at\" INTEGER NULL, \"notes\" TEXT NULL, \"created_by\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" INTEGER NOT NULL, \"base_updated_at\" INTEGER NULL, \"deleted_at\" INTEGER NULL, \"client_key\" TEXT NULL, PRIMARY KEY (\"id\"));" + "sql": "CREATE TABLE IF NOT EXISTS \"categories\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"icon\" TEXT NOT NULL, PRIMARY KEY (\"id\"));" } ] }, @@ -1576,11 +2071,11 @@ ] }, { - "name": "entry_snapshots", + "name": "group_events", "sql": [ { "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_snapshots\" (\"id\" TEXT NOT NULL, \"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"actor_id\" TEXT NULL REFERENCES members (id) ON DELETE CASCADE, \"created_at\" INTEGER NOT NULL, \"description\" TEXT NOT NULL, \"currency\" TEXT NOT NULL, \"amount_minor\" INTEGER NOT NULL, \"entry_date\" INTEGER NOT NULL, \"split_kind\" TEXT NOT NULL, \"category_id\" TEXT NULL, \"notes\" TEXT NULL, \"deleted_at\" INTEGER NULL, \"payers\" TEXT NOT NULL, \"shares\" TEXT NOT NULL, \"is_provisional\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_provisional\" IN (0, 1)), PRIMARY KEY (\"id\"));" + "sql": "CREATE TABLE IF NOT EXISTS \"group_events\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"actor_id\" TEXT NULL REFERENCES members (id) ON DELETE CASCADE, \"created_at\" TEXT NOT NULL, \"kind\" TEXT NOT NULL, \"subject_id\" TEXT NULL, \"entry\" TEXT NULL, \"member\" TEXT NULL, \"group\" TEXT NULL, \"link\" TEXT NULL, \"seq\" INTEGER NULL, \"ordinal\" INTEGER NULL, \"is_provisional\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_provisional\" IN (0, 1)), PRIMARY KEY (\"id\"));" } ] }, @@ -1589,7 +2084,7 @@ "sql": [ { "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_conflicts\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"attempted\" TEXT NOT NULL, \"rejected_at\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\"));" + "sql": "CREATE TABLE IF NOT EXISTS \"entry_conflicts\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"attempted\" TEXT NOT NULL, \"base_seq\" INTEGER NULL, \"rejected_at\" TEXT NOT NULL, PRIMARY KEY (\"entry_id\"));" } ] }, @@ -1607,16 +2102,25 @@ "sql": [ { "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"outbox\" (\"id\" TEXT NOT NULL, \"operation\" TEXT NOT NULL, \"target_id\" TEXT NOT NULL, \"revision\" TEXT NOT NULL DEFAULT '', \"payload\" TEXT NOT NULL DEFAULT '{}', \"created_at\" INTEGER NOT NULL, \"attempts\" INTEGER NOT NULL DEFAULT 0, \"next_attempt_at\" INTEGER NULL, \"last_error\" TEXT NULL, \"dead_lettered_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" + "sql": "CREATE TABLE IF NOT EXISTS \"outbox\" (\"target\" TEXT NOT NULL, \"target_id\" TEXT NOT NULL, \"revision\" TEXT NOT NULL, \"position\" INTEGER NOT NULL, \"attempts\" INTEGER NOT NULL DEFAULT 0, \"next_attempt_at\" TEXT NULL, \"last_error\" TEXT NULL, \"failure_code\" TEXT NULL, \"dead_lettered_at\" TEXT NULL, PRIMARY KEY (\"target\", \"target_id\"));" + } + ] + }, + { + "name": "group_cursors", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"group_cursors\" (\"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"seq\" INTEGER NOT NULL DEFAULT 0, \"last_synced_at\" TEXT NULL, PRIMARY KEY (\"group_id\"));" } ] }, { - "name": "sync_cursors", + "name": "feed_cursors", "sql": [ { "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_cursors\" (\"feed\" TEXT NOT NULL, \"cursor\" INTEGER NULL, \"cursor_id\" TEXT NULL, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"feed\"));" + "sql": "CREATE TABLE IF NOT EXISTS \"feed_cursors\" (\"feed\" TEXT NOT NULL, \"cursor\" TEXT NULL, \"last_synced_at\" TEXT NULL, PRIMARY KEY (\"feed\"));" } ] }, @@ -1625,7 +2129,7 @@ "sql": [ { "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_leases\" (\"name\" TEXT NOT NULL, \"owner\" TEXT NOT NULL, \"expires_at\" INTEGER NOT NULL, PRIMARY KEY (\"name\"));" + "sql": "CREATE TABLE IF NOT EXISTS \"sync_leases\" (\"name\" TEXT NOT NULL, \"owner\" TEXT NOT NULL, \"expires_at\" TEXT NOT NULL, PRIMARY KEY (\"name\"));" } ] }, @@ -1637,6 +2141,96 @@ "sql": "CREATE TABLE IF NOT EXISTS \"sync_sessions\" (\"id\" TEXT NOT NULL, \"epoch\" TEXT NOT NULL, \"enabled\" INTEGER NOT NULL CHECK (\"enabled\" IN (0, 1)), PRIMARY KEY (\"id\"));" } ] + }, + { + "name": "group_events_group", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX group_events_group ON group_events (group_id, seq, ordinal)" + } + ] + }, + { + "name": "group_events_subject", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX group_events_subject ON group_events (subject_id)" + } + ] + }, + { + "name": "group_events_actor", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX group_events_actor ON group_events (actor_id)" + } + ] + }, + { + "name": "members_group", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX members_group ON members (group_id)" + } + ] + }, + { + "name": "members_profile", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX members_profile ON members (profile_id)" + } + ] + }, + { + "name": "entries_group", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX entries_group ON entries (group_id, entry_date)" + } + ] + }, + { + "name": "entry_conflicts_group", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX entry_conflicts_group ON entry_conflicts (group_id)" + } + ] + }, + { + "name": "entry_payers_member", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX entry_payers_member ON entry_payers (member_id)" + } + ] + }, + { + "name": "entry_shares_member", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX entry_shares_member ON entry_shares (member_id)" + } + ] + }, + { + "name": "fx_rates_currency", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX fx_rates_currency ON fx_rates (currency, as_of)" + } + ] } ] } \ No newline at end of file diff --git a/drift_schemas/drift_schema_v10.json b/drift_schemas/drift_schema_v10.json deleted file mode 100644 index 6e3ba83..0000000 --- a/drift_schemas/drift_schema_v10.json +++ /dev/null @@ -1,2236 +0,0 @@ -{ - "_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": "currencies", - "was_declared_in_moor": false, - "columns": [ - { - "name": "code", - "getter_name": "code", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "allowed-lengths": { - "min": 3, - "max": 3 - } - } - ] - }, - { - "name": "exponent", - "getter_name": "exponent", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "symbol", - "getter_name": "symbol", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "name", - "getter_name": "name", - "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": [ - "code" - ] - } - }, - { - "id": 1, - "references": [ - 0 - ], - "type": "table", - "data": { - "name": "groups", - "was_declared_in_moor": false, - "columns": [ - { - "name": "avatar_kind", - "getter_name": "avatarKind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'initials\\'')", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(AvatarKind.values,AvatarKind.unknownDefaultOpenApi)", - "dart_type_name": "AvatarKind" - } - }, - { - "name": "avatar_color", - "getter_name": "avatarColor", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(AvatarColor.values,AvatarColor.unknownDefaultOpenApi)", - "dart_type_name": "AvatarColor" - } - }, - { - "name": "avatar_emoji", - "getter_name": "avatarEmoji", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "avatar_icon", - "getter_name": "avatarIcon", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(AvatarIcon.values,AvatarIcon.unknownDefaultOpenApi)", - "dart_type_name": "AvatarIcon" - } - }, - { - "name": "avatar_photo", - "getter_name": "avatarPhoto", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "id", - "getter_name": "id", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "default_currency", - "getter_name": "defaultCurrency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES currencies (code)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES currencies (code)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "currencies", - "column": "code" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "is_direct", - "getter_name": "isDirect", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"is_direct\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"is_direct\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "simplify_debts", - "getter_name": "simplifyDebts", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"simplify_debts\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"simplify_debts\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('1')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "cover_kind", - "getter_name": "coverKind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'generated\\'')", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(CoverKind.values,CoverKind.unknownDefaultOpenApi)", - "dart_type_name": "CoverKind" - } - }, - { - "name": "cover_photo", - "getter_name": "coverPhoto", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "archived_at", - "getter_name": "archivedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 2, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "entries", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "kind", - "getter_name": "kind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(EntryKind.values,EntryKind.unknownDefaultOpenApi)", - "dart_type_name": "EntryKind" - } - }, - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'\\'')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "category_id", - "getter_name": "categoryId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "currency", - "getter_name": "currency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "entry_date", - "getter_name": "entryDate", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const CalendarDateConverter()", - "dart_type_name": "DateTime" - } - }, - { - "name": "occurred_at", - "getter_name": "occurredAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "time_zone", - "getter_name": "timeZone", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "split_kind", - "getter_name": "splitKind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(SplitKind.values,SplitKind.unknownDefaultOpenApi)", - "dart_type_name": "SplitKind" - } - }, - { - "name": "fx_rate", - "getter_name": "fxRate", - "moor_type": "double", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "fx_source", - "getter_name": "fxSource", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "fx_at", - "getter_name": "fxAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "notes", - "getter_name": "notes", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "deleted_at", - "getter_name": "deletedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 3, - "references": [ - 2 - ], - "type": "table", - "data": { - "name": "entries_fts", - "was_declared_in_moor": true, - "columns": [ - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": "", - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "notes", - "getter_name": "notes", - "moor_type": "string", - "nullable": false, - "customConstraints": "", - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": true, - "create_virtual_stmt": "CREATE VIRTUAL TABLE \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid)", - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 4, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_insert", - "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" - } - }, - { - "id": 5, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_delete", - "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\nEND;" - } - }, - { - "id": 6, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_update", - "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" - } - }, - { - "id": 7, - "references": [], - "type": "table", - "data": { - "name": "profiles", - "was_declared_in_moor": false, - "columns": [ - { - "name": "avatar_kind", - "getter_name": "avatarKind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'initials\\'')", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(AvatarKind.values,AvatarKind.unknownDefaultOpenApi)", - "dart_type_name": "AvatarKind" - } - }, - { - "name": "avatar_color", - "getter_name": "avatarColor", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(AvatarColor.values,AvatarColor.unknownDefaultOpenApi)", - "dart_type_name": "AvatarColor" - } - }, - { - "name": "avatar_emoji", - "getter_name": "avatarEmoji", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "avatar_icon", - "getter_name": "avatarIcon", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(AvatarIcon.values,AvatarIcon.unknownDefaultOpenApi)", - "dart_type_name": "AvatarIcon" - } - }, - { - "name": "avatar_photo", - "getter_name": "avatarPhoto", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "id", - "getter_name": "id", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "display_name", - "getter_name": "displayName", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "upi_vpa", - "getter_name": "upiVpa", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "version", - "getter_name": "version", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 8, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "members", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "profile_id", - "getter_name": "profileId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "display_name", - "getter_name": "displayName", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "joined_at", - "getter_name": "joinedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "left_at", - "getter_name": "leftAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "upi_vpa", - "getter_name": "upiVpa", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 9, - "references": [], - "type": "table", - "data": { - "name": "categories", - "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": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "icon", - "getter_name": "icon", - "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": [ - "id" - ] - } - }, - { - "id": 10, - "references": [ - 2, - 8 - ], - "type": "table", - "data": { - "name": "entry_payers", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "member_id", - "getter_name": "memberId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id", - "member_id" - ] - } - }, - { - "id": 11, - "references": [ - 2, - 8 - ], - "type": "table", - "data": { - "name": "entry_shares", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "member_id", - "getter_name": "memberId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "weight_micros", - "getter_name": "weightMicros", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id", - "member_id" - ] - } - }, - { - "id": 12, - "references": [ - 1, - 8 - ], - "type": "table", - "data": { - "name": "group_events", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "actor_id", - "getter_name": "actorId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "kind", - "getter_name": "kind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(EventKind.values,EventKind.unknownDefaultOpenApi)", - "dart_type_name": "EventKind" - } - }, - { - "name": "subject_id", - "getter_name": "subjectId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "entry", - "getter_name": "entry", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(EntrySnapshot.fromJson)", - "dart_type_name": "EntrySnapshot" - } - }, - { - "name": "member", - "getter_name": "member", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(MemberEventPayload.fromJson)", - "dart_type_name": "MemberEventPayload" - } - }, - { - "name": "group", - "getter_name": "group", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(GroupEventPayload.fromJson)", - "dart_type_name": "GroupEventPayload" - } - }, - { - "name": "link", - "getter_name": "link", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(LinkEventPayload.fromJson)", - "dart_type_name": "LinkEventPayload" - } - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "ordinal", - "getter_name": "ordinal", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "is_provisional", - "getter_name": "isProvisional", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"is_provisional\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"is_provisional\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 13, - "references": [ - 2, - 1 - ], - "type": "table", - "data": { - "name": "entry_conflicts", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "attempted", - "getter_name": "attempted", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(EntrySnapshot.fromJson)", - "dart_type_name": "EntrySnapshot" - } - }, - { - "name": "base_seq", - "getter_name": "baseSeq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "rejected_at", - "getter_name": "rejectedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id" - ] - } - }, - { - "id": 14, - "references": [], - "type": "table", - "data": { - "name": "fx_rates", - "was_declared_in_moor": false, - "columns": [ - { - "name": "as_of", - "getter_name": "asOf", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "currency", - "getter_name": "currency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "rate", - "getter_name": "rate", - "moor_type": "double", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "source", - "getter_name": "source", - "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": [ - "as_of", - "currency" - ] - } - }, - { - "id": 15, - "references": [], - "type": "table", - "data": { - "name": "outbox", - "was_declared_in_moor": false, - "columns": [ - { - "name": "target", - "getter_name": "target", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(OutboxTarget.values)", - "dart_type_name": "OutboxTarget" - } - }, - { - "name": "target_id", - "getter_name": "targetId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "revision", - "getter_name": "revision", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "position", - "getter_name": "position", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "attempts", - "getter_name": "attempts", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "next_attempt_at", - "getter_name": "nextAttemptAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_error", - "getter_name": "lastError", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "dead_lettered_at", - "getter_name": "deadLetteredAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "target", - "target_id" - ] - } - }, - { - "id": 16, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "group_cursors", - "was_declared_in_moor": false, - "columns": [ - { - "name": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_synced_at", - "getter_name": "lastSyncedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "group_id" - ] - } - }, - { - "id": 17, - "references": [], - "type": "table", - "data": { - "name": "feed_cursors", - "was_declared_in_moor": false, - "columns": [ - { - "name": "feed", - "getter_name": "feed", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "cursor", - "getter_name": "cursor", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_synced_at", - "getter_name": "lastSyncedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "feed" - ] - } - }, - { - "id": 18, - "references": [], - "type": "table", - "data": { - "name": "sync_leases", - "was_declared_in_moor": false, - "columns": [ - { - "name": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "owner", - "getter_name": "owner", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "expires_at", - "getter_name": "expiresAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "name" - ] - } - }, - { - "id": 19, - "references": [], - "type": "table", - "data": { - "name": "sync_sessions", - "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": "epoch", - "getter_name": "epoch", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "enabled", - "getter_name": "enabled", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"enabled\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"enabled\" IN (0, 1))" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 20, - "references": [ - 12 - ], - "type": "index", - "data": { - "on": 12, - "name": "group_events_group", - "sql": null, - "unique": false, - "columns": [ - { - "column": "group_id", - "order_by": null - }, - { - "column": "seq", - "order_by": null - }, - { - "column": "ordinal", - "order_by": null - } - ] - } - }, - { - "id": 21, - "references": [ - 12 - ], - "type": "index", - "data": { - "on": 12, - "name": "group_events_subject", - "sql": null, - "unique": false, - "columns": [ - { - "column": "subject_id", - "order_by": null - } - ] - } - }, - { - "id": 22, - "references": [ - 12 - ], - "type": "index", - "data": { - "on": 12, - "name": "group_events_actor", - "sql": null, - "unique": false, - "columns": [ - { - "column": "actor_id", - "order_by": null - } - ] - } - }, - { - "id": 23, - "references": [ - 8 - ], - "type": "index", - "data": { - "on": 8, - "name": "members_group", - "sql": null, - "unique": false, - "columns": [ - { - "column": "group_id", - "order_by": null - } - ] - } - }, - { - "id": 24, - "references": [ - 8 - ], - "type": "index", - "data": { - "on": 8, - "name": "members_profile", - "sql": null, - "unique": false, - "columns": [ - { - "column": "profile_id", - "order_by": null - } - ] - } - }, - { - "id": 25, - "references": [ - 2 - ], - "type": "index", - "data": { - "on": 2, - "name": "entries_group", - "sql": null, - "unique": false, - "columns": [ - { - "column": "group_id", - "order_by": null - }, - { - "column": "entry_date", - "order_by": null - } - ] - } - }, - { - "id": 26, - "references": [ - 13 - ], - "type": "index", - "data": { - "on": 13, - "name": "entry_conflicts_group", - "sql": null, - "unique": false, - "columns": [ - { - "column": "group_id", - "order_by": null - } - ] - } - }, - { - "id": 27, - "references": [ - 10 - ], - "type": "index", - "data": { - "on": 10, - "name": "entry_payers_member", - "sql": null, - "unique": false, - "columns": [ - { - "column": "member_id", - "order_by": null - } - ] - } - }, - { - "id": 28, - "references": [ - 11 - ], - "type": "index", - "data": { - "on": 11, - "name": "entry_shares_member", - "sql": null, - "unique": false, - "columns": [ - { - "column": "member_id", - "order_by": null - } - ] - } - }, - { - "id": 29, - "references": [ - 14 - ], - "type": "index", - "data": { - "on": 14, - "name": "fx_rates_currency", - "sql": null, - "unique": false, - "columns": [ - { - "column": "currency", - "order_by": null - }, - { - "column": "as_of", - "order_by": null - } - ] - } - } - ], - "fixed_sql": [ - { - "name": "currencies", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"currencies\" (\"code\" TEXT NOT NULL, \"exponent\" INTEGER NOT NULL, \"symbol\" TEXT NULL, \"name\" TEXT NOT NULL, PRIMARY KEY (\"code\"));" - } - ] - }, - { - "name": "groups", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"groups\" (\"avatar_kind\" TEXT NOT NULL DEFAULT 'initials', \"avatar_color\" TEXT NULL, \"avatar_emoji\" TEXT NULL, \"avatar_icon\" TEXT NULL, \"avatar_photo\" TEXT NULL, \"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"default_currency\" TEXT NOT NULL REFERENCES currencies (code), \"is_direct\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_direct\" IN (0, 1)), \"simplify_debts\" INTEGER NOT NULL DEFAULT 1 CHECK (\"simplify_debts\" IN (0, 1)), \"cover_kind\" TEXT NOT NULL DEFAULT 'generated', \"cover_photo\" TEXT NULL, \"created_by\" TEXT NULL, \"created_at\" INTEGER NOT NULL, \"archived_at\" INTEGER NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entries", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entries\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"kind\" TEXT NOT NULL, \"description\" TEXT NOT NULL DEFAULT '', \"category_id\" TEXT NULL, \"currency\" TEXT NOT NULL, \"amount_minor\" INTEGER NOT NULL, \"entry_date\" TEXT NOT NULL, \"occurred_at\" INTEGER NULL, \"time_zone\" TEXT NULL, \"split_kind\" TEXT NOT NULL, \"fx_rate\" REAL NULL, \"fx_source\" TEXT NULL, \"fx_at\" INTEGER NULL, \"notes\" TEXT NULL, \"created_by\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"seq\" INTEGER NULL, \"deleted_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entries_fts", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid);" - } - ] - }, - { - "name": "entries_fts_insert", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" - } - ] - }, - { - "name": "entries_fts_delete", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);END" - } - ] - }, - { - "name": "entries_fts_update", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" - } - ] - }, - { - "name": "profiles", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"profiles\" (\"avatar_kind\" TEXT NOT NULL DEFAULT 'initials', \"avatar_color\" TEXT NULL, \"avatar_emoji\" TEXT NULL, \"avatar_icon\" TEXT NULL, \"avatar_photo\" TEXT NULL, \"id\" TEXT NOT NULL, \"display_name\" TEXT NULL, \"upi_vpa\" TEXT NULL, \"version\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "members", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"members\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"profile_id\" TEXT NULL, \"display_name\" TEXT NOT NULL, \"joined_at\" INTEGER NOT NULL, \"left_at\" INTEGER NULL, \"upi_vpa\" TEXT NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "categories", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"categories\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"icon\" TEXT NOT NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entry_payers", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_payers\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" - } - ] - }, - { - "name": "entry_shares", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_shares\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, \"weight_micros\" INTEGER NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" - } - ] - }, - { - "name": "group_events", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"group_events\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"actor_id\" TEXT NULL REFERENCES members (id) ON DELETE CASCADE, \"created_at\" INTEGER NOT NULL, \"kind\" TEXT NOT NULL, \"subject_id\" TEXT NULL, \"entry\" TEXT NULL, \"member\" TEXT NULL, \"group\" TEXT NULL, \"link\" TEXT NULL, \"seq\" INTEGER NULL, \"ordinal\" INTEGER NULL, \"is_provisional\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_provisional\" IN (0, 1)), PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entry_conflicts", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_conflicts\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"attempted\" TEXT NOT NULL, \"base_seq\" INTEGER NULL, \"rejected_at\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\"));" - } - ] - }, - { - "name": "fx_rates", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"fx_rates\" (\"as_of\" TEXT NOT NULL, \"currency\" TEXT NOT NULL, \"rate\" REAL NOT NULL, \"source\" TEXT NOT NULL, PRIMARY KEY (\"as_of\", \"currency\"));" - } - ] - }, - { - "name": "outbox", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"outbox\" (\"target\" TEXT NOT NULL, \"target_id\" TEXT NOT NULL, \"revision\" TEXT NOT NULL, \"position\" INTEGER NOT NULL, \"attempts\" INTEGER NOT NULL DEFAULT 0, \"next_attempt_at\" INTEGER NULL, \"last_error\" TEXT NULL, \"dead_lettered_at\" INTEGER NULL, PRIMARY KEY (\"target\", \"target_id\"));" - } - ] - }, - { - "name": "group_cursors", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"group_cursors\" (\"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"seq\" INTEGER NOT NULL DEFAULT 0, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"group_id\"));" - } - ] - }, - { - "name": "feed_cursors", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"feed_cursors\" (\"feed\" TEXT NOT NULL, \"cursor\" TEXT NULL, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"feed\"));" - } - ] - }, - { - "name": "sync_leases", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_leases\" (\"name\" TEXT NOT NULL, \"owner\" TEXT NOT NULL, \"expires_at\" INTEGER NOT NULL, PRIMARY KEY (\"name\"));" - } - ] - }, - { - "name": "sync_sessions", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_sessions\" (\"id\" TEXT NOT NULL, \"epoch\" TEXT NOT NULL, \"enabled\" INTEGER NOT NULL CHECK (\"enabled\" IN (0, 1)), PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "group_events_group", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE INDEX group_events_group ON group_events (group_id, seq, ordinal)" - } - ] - }, - { - "name": "group_events_subject", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE INDEX group_events_subject ON group_events (subject_id)" - } - ] - }, - { - "name": "group_events_actor", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE INDEX group_events_actor ON group_events (actor_id)" - } - ] - }, - { - "name": "members_group", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE INDEX members_group ON members (group_id)" - } - ] - }, - { - "name": "members_profile", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE INDEX members_profile ON members (profile_id)" - } - ] - }, - { - "name": "entries_group", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE INDEX entries_group ON entries (group_id, entry_date)" - } - ] - }, - { - "name": "entry_conflicts_group", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE INDEX entry_conflicts_group ON entry_conflicts (group_id)" - } - ] - }, - { - "name": "entry_payers_member", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE INDEX entry_payers_member ON entry_payers (member_id)" - } - ] - }, - { - "name": "entry_shares_member", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE INDEX entry_shares_member ON entry_shares (member_id)" - } - ] - }, - { - "name": "fx_rates_currency", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE INDEX fx_rates_currency ON fx_rates (currency, as_of)" - } - ] - } - ] -} \ No newline at end of file diff --git a/drift_schemas/drift_schema_v2.json b/drift_schemas/drift_schema_v2.json deleted file mode 100644 index d703886..0000000 --- a/drift_schemas/drift_schema_v2.json +++ /dev/null @@ -1,1665 +0,0 @@ -{ - "_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": "currencies", - "was_declared_in_moor": false, - "columns": [ - { - "name": "code", - "getter_name": "code", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "allowed-lengths": { - "min": 3, - "max": 3 - } - } - ] - }, - { - "name": "exponent", - "getter_name": "exponent", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "symbol", - "getter_name": "symbol", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "name", - "getter_name": "name", - "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": [ - "code" - ] - } - }, - { - "id": 1, - "references": [ - 0 - ], - "type": "table", - "data": { - "name": "groups", - "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": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "default_currency", - "getter_name": "defaultCurrency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES currencies (code)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES currencies (code)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "currencies", - "column": "code" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "is_direct", - "getter_name": "isDirect", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"is_direct\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"is_direct\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "simplify_debts", - "getter_name": "simplifyDebts", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"simplify_debts\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"simplify_debts\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('1')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "archived_at", - "getter_name": "archivedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "updated_at", - "getter_name": "updatedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')", - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 2, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "entries", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "kind", - "getter_name": "kind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(EntryKind.values)", - "dart_type_name": "EntryKind" - } - }, - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'\\'')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "category_id", - "getter_name": "categoryId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "currency", - "getter_name": "currency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "entry_date", - "getter_name": "entryDate", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "split_kind", - "getter_name": "splitKind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(SplitKind.values)", - "dart_type_name": "SplitKind" - } - }, - { - "name": "fx_rate", - "getter_name": "fxRate", - "moor_type": "double", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "fx_source", - "getter_name": "fxSource", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "fx_at", - "getter_name": "fxAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "notes", - "getter_name": "notes", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "updated_at", - "getter_name": "updatedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "base_updated_at", - "getter_name": "baseUpdatedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "deleted_at", - "getter_name": "deletedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "client_key", - "getter_name": "clientKey", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 3, - "references": [ - 2 - ], - "type": "table", - "data": { - "name": "entries_fts", - "was_declared_in_moor": true, - "columns": [ - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": "", - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "notes", - "getter_name": "notes", - "moor_type": "string", - "nullable": false, - "customConstraints": "", - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": true, - "create_virtual_stmt": "CREATE VIRTUAL TABLE \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid)", - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 4, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_insert", - "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" - } - }, - { - "id": 5, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_delete", - "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\nEND;" - } - }, - { - "id": 6, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_update", - "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" - } - }, - { - "id": 7, - "references": [], - "type": "table", - "data": { - "name": "profiles", - "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": "display_name", - "getter_name": "displayName", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "avatar_url", - "getter_name": "avatarUrl", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "upi_vpa", - "getter_name": "upiVpa", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "updated_at", - "getter_name": "updatedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 8, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "members", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "profile_id", - "getter_name": "profileId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "display_name", - "getter_name": "displayName", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "joined_at", - "getter_name": "joinedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "left_at", - "getter_name": "leftAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "upi_vpa", - "getter_name": "upiVpa", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "updated_at", - "getter_name": "updatedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')", - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 9, - "references": [], - "type": "table", - "data": { - "name": "categories", - "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": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "icon", - "getter_name": "icon", - "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": [ - "id" - ] - } - }, - { - "id": 10, - "references": [ - 2, - 8 - ], - "type": "table", - "data": { - "name": "entry_payers", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "member_id", - "getter_name": "memberId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id", - "member_id" - ] - } - }, - { - "id": 11, - "references": [ - 2, - 8 - ], - "type": "table", - "data": { - "name": "entry_shares", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "member_id", - "getter_name": "memberId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "weight_micros", - "getter_name": "weightMicros", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id", - "member_id" - ] - } - }, - { - "id": 12, - "references": [ - 1, - 8 - ], - "type": "table", - "data": { - "name": "group_events", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "actor_id", - "getter_name": "actorId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "kind", - "getter_name": "kind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "subject_id", - "getter_name": "subjectId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "payload", - "getter_name": "payload", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "is_provisional", - "getter_name": "isProvisional", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"is_provisional\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"is_provisional\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 13, - "references": [ - 2, - 1 - ], - "type": "table", - "data": { - "name": "entry_conflicts", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "attempted", - "getter_name": "attempted", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "rejected_at", - "getter_name": "rejectedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id" - ] - } - }, - { - "id": 14, - "references": [], - "type": "table", - "data": { - "name": "fx_rates", - "was_declared_in_moor": false, - "columns": [ - { - "name": "as_of", - "getter_name": "asOf", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "currency", - "getter_name": "currency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "rate", - "getter_name": "rate", - "moor_type": "double", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "source", - "getter_name": "source", - "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": [ - "as_of", - "currency" - ] - } - }, - { - "id": 15, - "references": [], - "type": "table", - "data": { - "name": "outbox", - "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": "operation", - "getter_name": "operation", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "target_id", - "getter_name": "targetId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "revision", - "getter_name": "revision", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'\\'')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "payload", - "getter_name": "payload", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'{}\\'')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "attempts", - "getter_name": "attempts", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "next_attempt_at", - "getter_name": "nextAttemptAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_error", - "getter_name": "lastError", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "dead_lettered_at", - "getter_name": "deadLetteredAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 16, - "references": [], - "type": "table", - "data": { - "name": "sync_cursors", - "was_declared_in_moor": false, - "columns": [ - { - "name": "feed", - "getter_name": "feed", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "cursor", - "getter_name": "cursor", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "cursor_id", - "getter_name": "cursorId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_synced_at", - "getter_name": "lastSyncedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "feed" - ] - } - }, - { - "id": 17, - "references": [], - "type": "table", - "data": { - "name": "sync_leases", - "was_declared_in_moor": false, - "columns": [ - { - "name": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "owner", - "getter_name": "owner", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "expires_at", - "getter_name": "expiresAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "name" - ] - } - }, - { - "id": 18, - "references": [], - "type": "table", - "data": { - "name": "sync_sessions", - "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": "epoch", - "getter_name": "epoch", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "enabled", - "getter_name": "enabled", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"enabled\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"enabled\" IN (0, 1))" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - } - ], - "fixed_sql": [ - { - "name": "currencies", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"currencies\" (\"code\" TEXT NOT NULL, \"exponent\" INTEGER NOT NULL, \"symbol\" TEXT NULL, \"name\" TEXT NOT NULL, PRIMARY KEY (\"code\"));" - } - ] - }, - { - "name": "groups", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"groups\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"default_currency\" TEXT NOT NULL REFERENCES currencies (code), \"is_direct\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_direct\" IN (0, 1)), \"simplify_debts\" INTEGER NOT NULL DEFAULT 1 CHECK (\"simplify_debts\" IN (0, 1)), \"created_by\" TEXT NULL, \"created_at\" INTEGER NOT NULL, \"archived_at\" INTEGER NULL, \"updated_at\" INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)), PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entries", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entries\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"kind\" TEXT NOT NULL, \"description\" TEXT NOT NULL DEFAULT '', \"category_id\" TEXT NULL, \"currency\" TEXT NOT NULL, \"amount_minor\" INTEGER NOT NULL, \"entry_date\" INTEGER NOT NULL, \"split_kind\" TEXT NOT NULL, \"fx_rate\" REAL NULL, \"fx_source\" TEXT NULL, \"fx_at\" INTEGER NULL, \"notes\" TEXT NULL, \"created_by\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" INTEGER NOT NULL, \"base_updated_at\" INTEGER NULL, \"deleted_at\" INTEGER NULL, \"client_key\" TEXT NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entries_fts", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid);" - } - ] - }, - { - "name": "entries_fts_insert", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" - } - ] - }, - { - "name": "entries_fts_delete", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);END" - } - ] - }, - { - "name": "entries_fts_update", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" - } - ] - }, - { - "name": "profiles", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"profiles\" (\"id\" TEXT NOT NULL, \"display_name\" TEXT NULL, \"avatar_url\" TEXT NULL, \"upi_vpa\" TEXT NULL, \"updated_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "members", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"members\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"profile_id\" TEXT NULL, \"display_name\" TEXT NOT NULL, \"joined_at\" INTEGER NOT NULL, \"left_at\" INTEGER NULL, \"upi_vpa\" TEXT NULL, \"updated_at\" INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)), PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "categories", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"categories\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"icon\" TEXT NOT NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entry_payers", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_payers\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" - } - ] - }, - { - "name": "entry_shares", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_shares\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, \"weight_micros\" INTEGER NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" - } - ] - }, - { - "name": "group_events", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"group_events\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"actor_id\" TEXT NULL REFERENCES members (id) ON DELETE CASCADE, \"created_at\" INTEGER NOT NULL, \"kind\" TEXT NOT NULL, \"subject_id\" TEXT NULL, \"payload\" TEXT NOT NULL, \"is_provisional\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_provisional\" IN (0, 1)), PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entry_conflicts", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_conflicts\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"attempted\" TEXT NOT NULL, \"rejected_at\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\"));" - } - ] - }, - { - "name": "fx_rates", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"fx_rates\" (\"as_of\" TEXT NOT NULL, \"currency\" TEXT NOT NULL, \"rate\" REAL NOT NULL, \"source\" TEXT NOT NULL, PRIMARY KEY (\"as_of\", \"currency\"));" - } - ] - }, - { - "name": "outbox", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"outbox\" (\"id\" TEXT NOT NULL, \"operation\" TEXT NOT NULL, \"target_id\" TEXT NOT NULL, \"revision\" TEXT NOT NULL DEFAULT '', \"payload\" TEXT NOT NULL DEFAULT '{}', \"created_at\" INTEGER NOT NULL, \"attempts\" INTEGER NOT NULL DEFAULT 0, \"next_attempt_at\" INTEGER NULL, \"last_error\" TEXT NULL, \"dead_lettered_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "sync_cursors", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_cursors\" (\"feed\" TEXT NOT NULL, \"cursor\" INTEGER NULL, \"cursor_id\" TEXT NULL, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"feed\"));" - } - ] - }, - { - "name": "sync_leases", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_leases\" (\"name\" TEXT NOT NULL, \"owner\" TEXT NOT NULL, \"expires_at\" INTEGER NOT NULL, PRIMARY KEY (\"name\"));" - } - ] - }, - { - "name": "sync_sessions", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_sessions\" (\"id\" TEXT NOT NULL, \"epoch\" TEXT NOT NULL, \"enabled\" INTEGER NOT NULL CHECK (\"enabled\" IN (0, 1)), PRIMARY KEY (\"id\"));" - } - ] - } - ] -} \ No newline at end of file diff --git a/drift_schemas/drift_schema_v3.json b/drift_schemas/drift_schema_v3.json deleted file mode 100644 index c0a1f21..0000000 --- a/drift_schemas/drift_schema_v3.json +++ /dev/null @@ -1,1759 +0,0 @@ -{ - "_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": "currencies", - "was_declared_in_moor": false, - "columns": [ - { - "name": "code", - "getter_name": "code", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "allowed-lengths": { - "min": 3, - "max": 3 - } - } - ] - }, - { - "name": "exponent", - "getter_name": "exponent", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "symbol", - "getter_name": "symbol", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "name", - "getter_name": "name", - "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": [ - "code" - ] - } - }, - { - "id": 1, - "references": [ - 0 - ], - "type": "table", - "data": { - "name": "groups", - "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": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "default_currency", - "getter_name": "defaultCurrency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES currencies (code)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES currencies (code)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "currencies", - "column": "code" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "is_direct", - "getter_name": "isDirect", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"is_direct\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"is_direct\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "simplify_debts", - "getter_name": "simplifyDebts", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"simplify_debts\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"simplify_debts\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('1')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "archived_at", - "getter_name": "archivedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 2, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "entries", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "kind", - "getter_name": "kind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(EntryKind.values)", - "dart_type_name": "EntryKind" - } - }, - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'\\'')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "category_id", - "getter_name": "categoryId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "currency", - "getter_name": "currency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "entry_date", - "getter_name": "entryDate", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "split_kind", - "getter_name": "splitKind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(SplitKind.values)", - "dart_type_name": "SplitKind" - } - }, - { - "name": "fx_rate", - "getter_name": "fxRate", - "moor_type": "double", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "fx_source", - "getter_name": "fxSource", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "fx_at", - "getter_name": "fxAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "notes", - "getter_name": "notes", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "deleted_at", - "getter_name": "deletedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "client_key", - "getter_name": "clientKey", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 3, - "references": [ - 2 - ], - "type": "table", - "data": { - "name": "entries_fts", - "was_declared_in_moor": true, - "columns": [ - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": "", - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "notes", - "getter_name": "notes", - "moor_type": "string", - "nullable": false, - "customConstraints": "", - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": true, - "create_virtual_stmt": "CREATE VIRTUAL TABLE \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid)", - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 4, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_insert", - "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" - } - }, - { - "id": 5, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_delete", - "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\nEND;" - } - }, - { - "id": 6, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_update", - "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" - } - }, - { - "id": 7, - "references": [], - "type": "table", - "data": { - "name": "profiles", - "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": "display_name", - "getter_name": "displayName", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "avatar_url", - "getter_name": "avatarUrl", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "upi_vpa", - "getter_name": "upiVpa", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "updated_at", - "getter_name": "updatedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 8, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "members", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "profile_id", - "getter_name": "profileId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "display_name", - "getter_name": "displayName", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "joined_at", - "getter_name": "joinedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "left_at", - "getter_name": "leftAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "upi_vpa", - "getter_name": "upiVpa", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 9, - "references": [], - "type": "table", - "data": { - "name": "categories", - "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": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "icon", - "getter_name": "icon", - "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": [ - "id" - ] - } - }, - { - "id": 10, - "references": [ - 2, - 8 - ], - "type": "table", - "data": { - "name": "entry_payers", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "member_id", - "getter_name": "memberId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id", - "member_id" - ] - } - }, - { - "id": 11, - "references": [ - 2, - 8 - ], - "type": "table", - "data": { - "name": "entry_shares", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "member_id", - "getter_name": "memberId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "weight_micros", - "getter_name": "weightMicros", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id", - "member_id" - ] - } - }, - { - "id": 12, - "references": [ - 1, - 8 - ], - "type": "table", - "data": { - "name": "group_events", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "actor_id", - "getter_name": "actorId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "kind", - "getter_name": "kind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "subject_id", - "getter_name": "subjectId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "payload", - "getter_name": "payload", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "ordinal", - "getter_name": "ordinal", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "is_provisional", - "getter_name": "isProvisional", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"is_provisional\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"is_provisional\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 13, - "references": [ - 2, - 1 - ], - "type": "table", - "data": { - "name": "entry_conflicts", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "attempted", - "getter_name": "attempted", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "base_seq", - "getter_name": "baseSeq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "rejected_at", - "getter_name": "rejectedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id" - ] - } - }, - { - "id": 14, - "references": [], - "type": "table", - "data": { - "name": "fx_rates", - "was_declared_in_moor": false, - "columns": [ - { - "name": "as_of", - "getter_name": "asOf", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "currency", - "getter_name": "currency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "rate", - "getter_name": "rate", - "moor_type": "double", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "source", - "getter_name": "source", - "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": [ - "as_of", - "currency" - ] - } - }, - { - "id": 15, - "references": [], - "type": "table", - "data": { - "name": "outbox", - "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": "operation", - "getter_name": "operation", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "target_id", - "getter_name": "targetId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "revision", - "getter_name": "revision", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'\\'')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "payload", - "getter_name": "payload", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'{}\\'')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "attempts", - "getter_name": "attempts", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "next_attempt_at", - "getter_name": "nextAttemptAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_error", - "getter_name": "lastError", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "dead_lettered_at", - "getter_name": "deadLetteredAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 16, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "group_cursors", - "was_declared_in_moor": false, - "columns": [ - { - "name": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_synced_at", - "getter_name": "lastSyncedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "group_id" - ] - } - }, - { - "id": 17, - "references": [], - "type": "table", - "data": { - "name": "feed_cursors", - "was_declared_in_moor": false, - "columns": [ - { - "name": "feed", - "getter_name": "feed", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "cursor", - "getter_name": "cursor", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "cursor_id", - "getter_name": "cursorId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_synced_at", - "getter_name": "lastSyncedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "feed" - ] - } - }, - { - "id": 18, - "references": [], - "type": "table", - "data": { - "name": "sync_leases", - "was_declared_in_moor": false, - "columns": [ - { - "name": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "owner", - "getter_name": "owner", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "expires_at", - "getter_name": "expiresAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "name" - ] - } - }, - { - "id": 19, - "references": [], - "type": "table", - "data": { - "name": "sync_sessions", - "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": "epoch", - "getter_name": "epoch", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "enabled", - "getter_name": "enabled", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"enabled\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"enabled\" IN (0, 1))" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - } - ], - "fixed_sql": [ - { - "name": "currencies", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"currencies\" (\"code\" TEXT NOT NULL, \"exponent\" INTEGER NOT NULL, \"symbol\" TEXT NULL, \"name\" TEXT NOT NULL, PRIMARY KEY (\"code\"));" - } - ] - }, - { - "name": "groups", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"groups\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"default_currency\" TEXT NOT NULL REFERENCES currencies (code), \"is_direct\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_direct\" IN (0, 1)), \"simplify_debts\" INTEGER NOT NULL DEFAULT 1 CHECK (\"simplify_debts\" IN (0, 1)), \"created_by\" TEXT NULL, \"created_at\" INTEGER NOT NULL, \"archived_at\" INTEGER NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entries", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entries\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"kind\" TEXT NOT NULL, \"description\" TEXT NOT NULL DEFAULT '', \"category_id\" TEXT NULL, \"currency\" TEXT NOT NULL, \"amount_minor\" INTEGER NOT NULL, \"entry_date\" INTEGER NOT NULL, \"split_kind\" TEXT NOT NULL, \"fx_rate\" REAL NULL, \"fx_source\" TEXT NULL, \"fx_at\" INTEGER NULL, \"notes\" TEXT NULL, \"created_by\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"seq\" INTEGER NULL, \"deleted_at\" INTEGER NULL, \"client_key\" TEXT NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entries_fts", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid);" - } - ] - }, - { - "name": "entries_fts_insert", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" - } - ] - }, - { - "name": "entries_fts_delete", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);END" - } - ] - }, - { - "name": "entries_fts_update", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" - } - ] - }, - { - "name": "profiles", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"profiles\" (\"id\" TEXT NOT NULL, \"display_name\" TEXT NULL, \"avatar_url\" TEXT NULL, \"upi_vpa\" TEXT NULL, \"updated_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "members", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"members\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"profile_id\" TEXT NULL, \"display_name\" TEXT NOT NULL, \"joined_at\" INTEGER NOT NULL, \"left_at\" INTEGER NULL, \"upi_vpa\" TEXT NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "categories", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"categories\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"icon\" TEXT NOT NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entry_payers", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_payers\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" - } - ] - }, - { - "name": "entry_shares", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_shares\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, \"weight_micros\" INTEGER NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" - } - ] - }, - { - "name": "group_events", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"group_events\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"actor_id\" TEXT NULL REFERENCES members (id) ON DELETE CASCADE, \"created_at\" INTEGER NOT NULL, \"kind\" TEXT NOT NULL, \"subject_id\" TEXT NULL, \"payload\" TEXT NOT NULL, \"seq\" INTEGER NULL, \"ordinal\" INTEGER NULL, \"is_provisional\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_provisional\" IN (0, 1)), PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entry_conflicts", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_conflicts\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"attempted\" TEXT NOT NULL, \"base_seq\" INTEGER NULL, \"rejected_at\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\"));" - } - ] - }, - { - "name": "fx_rates", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"fx_rates\" (\"as_of\" TEXT NOT NULL, \"currency\" TEXT NOT NULL, \"rate\" REAL NOT NULL, \"source\" TEXT NOT NULL, PRIMARY KEY (\"as_of\", \"currency\"));" - } - ] - }, - { - "name": "outbox", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"outbox\" (\"id\" TEXT NOT NULL, \"operation\" TEXT NOT NULL, \"target_id\" TEXT NOT NULL, \"revision\" TEXT NOT NULL DEFAULT '', \"payload\" TEXT NOT NULL DEFAULT '{}', \"created_at\" INTEGER NOT NULL, \"attempts\" INTEGER NOT NULL DEFAULT 0, \"next_attempt_at\" INTEGER NULL, \"last_error\" TEXT NULL, \"dead_lettered_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "group_cursors", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"group_cursors\" (\"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"seq\" INTEGER NOT NULL DEFAULT 0, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"group_id\"));" - } - ] - }, - { - "name": "feed_cursors", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"feed_cursors\" (\"feed\" TEXT NOT NULL, \"cursor\" INTEGER NULL, \"cursor_id\" TEXT NULL, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"feed\"));" - } - ] - }, - { - "name": "sync_leases", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_leases\" (\"name\" TEXT NOT NULL, \"owner\" TEXT NOT NULL, \"expires_at\" INTEGER NOT NULL, PRIMARY KEY (\"name\"));" - } - ] - }, - { - "name": "sync_sessions", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_sessions\" (\"id\" TEXT NOT NULL, \"epoch\" TEXT NOT NULL, \"enabled\" INTEGER NOT NULL CHECK (\"enabled\" IN (0, 1)), PRIMARY KEY (\"id\"));" - } - ] - } - ] -} \ No newline at end of file diff --git a/drift_schemas/drift_schema_v4.json b/drift_schemas/drift_schema_v4.json deleted file mode 100644 index d7a7fb8..0000000 --- a/drift_schemas/drift_schema_v4.json +++ /dev/null @@ -1,1761 +0,0 @@ -{ - "_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": "currencies", - "was_declared_in_moor": false, - "columns": [ - { - "name": "code", - "getter_name": "code", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "allowed-lengths": { - "min": 3, - "max": 3 - } - } - ] - }, - { - "name": "exponent", - "getter_name": "exponent", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "symbol", - "getter_name": "symbol", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "name", - "getter_name": "name", - "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": [ - "code" - ] - } - }, - { - "id": 1, - "references": [ - 0 - ], - "type": "table", - "data": { - "name": "groups", - "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": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "default_currency", - "getter_name": "defaultCurrency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES currencies (code)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES currencies (code)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "currencies", - "column": "code" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "is_direct", - "getter_name": "isDirect", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"is_direct\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"is_direct\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "simplify_debts", - "getter_name": "simplifyDebts", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"simplify_debts\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"simplify_debts\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('1')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "archived_at", - "getter_name": "archivedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 2, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "entries", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "kind", - "getter_name": "kind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(EntryKind.values,EntryKind.unknownDefaultOpenApi)", - "dart_type_name": "EntryKind" - } - }, - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'\\'')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "category_id", - "getter_name": "categoryId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "currency", - "getter_name": "currency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "entry_date", - "getter_name": "entryDate", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "split_kind", - "getter_name": "splitKind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(SplitKind.values,SplitKind.unknownDefaultOpenApi)", - "dart_type_name": "SplitKind" - } - }, - { - "name": "fx_rate", - "getter_name": "fxRate", - "moor_type": "double", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "fx_source", - "getter_name": "fxSource", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "fx_at", - "getter_name": "fxAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "notes", - "getter_name": "notes", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "deleted_at", - "getter_name": "deletedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "client_key", - "getter_name": "clientKey", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 3, - "references": [ - 2 - ], - "type": "table", - "data": { - "name": "entries_fts", - "was_declared_in_moor": true, - "columns": [ - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": "", - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "notes", - "getter_name": "notes", - "moor_type": "string", - "nullable": false, - "customConstraints": "", - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": true, - "create_virtual_stmt": "CREATE VIRTUAL TABLE \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid)", - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 4, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_insert", - "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" - } - }, - { - "id": 5, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_delete", - "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\nEND;" - } - }, - { - "id": 6, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_update", - "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" - } - }, - { - "id": 7, - "references": [], - "type": "table", - "data": { - "name": "profiles", - "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": "display_name", - "getter_name": "displayName", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "upi_vpa", - "getter_name": "upiVpa", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "updated_at", - "getter_name": "updatedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 8, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "members", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "profile_id", - "getter_name": "profileId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "display_name", - "getter_name": "displayName", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "joined_at", - "getter_name": "joinedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "left_at", - "getter_name": "leftAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "upi_vpa", - "getter_name": "upiVpa", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 9, - "references": [], - "type": "table", - "data": { - "name": "categories", - "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": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "icon", - "getter_name": "icon", - "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": [ - "id" - ] - } - }, - { - "id": 10, - "references": [ - 2, - 8 - ], - "type": "table", - "data": { - "name": "entry_payers", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "member_id", - "getter_name": "memberId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id", - "member_id" - ] - } - }, - { - "id": 11, - "references": [ - 2, - 8 - ], - "type": "table", - "data": { - "name": "entry_shares", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "member_id", - "getter_name": "memberId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "weight_micros", - "getter_name": "weightMicros", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id", - "member_id" - ] - } - }, - { - "id": 12, - "references": [ - 1, - 8 - ], - "type": "table", - "data": { - "name": "group_events", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "actor_id", - "getter_name": "actorId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "kind", - "getter_name": "kind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(EventKind.values,EventKind.unknownDefaultOpenApi)", - "dart_type_name": "EventKind" - } - }, - { - "name": "subject_id", - "getter_name": "subjectId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "payload", - "getter_name": "payload", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const JsonMapConverter()", - "dart_type_name": "Map" - } - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "ordinal", - "getter_name": "ordinal", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "is_provisional", - "getter_name": "isProvisional", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"is_provisional\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"is_provisional\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 13, - "references": [ - 2, - 1 - ], - "type": "table", - "data": { - "name": "entry_conflicts", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "attempted", - "getter_name": "attempted", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "base_seq", - "getter_name": "baseSeq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "rejected_at", - "getter_name": "rejectedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id" - ] - } - }, - { - "id": 14, - "references": [], - "type": "table", - "data": { - "name": "fx_rates", - "was_declared_in_moor": false, - "columns": [ - { - "name": "as_of", - "getter_name": "asOf", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "currency", - "getter_name": "currency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "rate", - "getter_name": "rate", - "moor_type": "double", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "source", - "getter_name": "source", - "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": [ - "as_of", - "currency" - ] - } - }, - { - "id": 15, - "references": [], - "type": "table", - "data": { - "name": "outbox", - "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": "operation", - "getter_name": "operation", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(OutboxTarget.values)", - "dart_type_name": "OutboxTarget" - } - }, - { - "name": "target_id", - "getter_name": "targetId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "revision", - "getter_name": "revision", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'\\'')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "payload", - "getter_name": "payload", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'{}\\'')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "attempts", - "getter_name": "attempts", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "next_attempt_at", - "getter_name": "nextAttemptAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_error", - "getter_name": "lastError", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "dead_lettered_at", - "getter_name": "deadLetteredAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 16, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "group_cursors", - "was_declared_in_moor": false, - "columns": [ - { - "name": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_synced_at", - "getter_name": "lastSyncedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "group_id" - ] - } - }, - { - "id": 17, - "references": [], - "type": "table", - "data": { - "name": "feed_cursors", - "was_declared_in_moor": false, - "columns": [ - { - "name": "feed", - "getter_name": "feed", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "cursor", - "getter_name": "cursor", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "cursor_id", - "getter_name": "cursorId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_synced_at", - "getter_name": "lastSyncedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "feed" - ] - } - }, - { - "id": 18, - "references": [], - "type": "table", - "data": { - "name": "sync_leases", - "was_declared_in_moor": false, - "columns": [ - { - "name": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "owner", - "getter_name": "owner", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "expires_at", - "getter_name": "expiresAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "name" - ] - } - }, - { - "id": 19, - "references": [], - "type": "table", - "data": { - "name": "sync_sessions", - "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": "epoch", - "getter_name": "epoch", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "enabled", - "getter_name": "enabled", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"enabled\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"enabled\" IN (0, 1))" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - } - ], - "fixed_sql": [ - { - "name": "currencies", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"currencies\" (\"code\" TEXT NOT NULL, \"exponent\" INTEGER NOT NULL, \"symbol\" TEXT NULL, \"name\" TEXT NOT NULL, PRIMARY KEY (\"code\"));" - } - ] - }, - { - "name": "groups", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"groups\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"default_currency\" TEXT NOT NULL REFERENCES currencies (code), \"is_direct\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_direct\" IN (0, 1)), \"simplify_debts\" INTEGER NOT NULL DEFAULT 1 CHECK (\"simplify_debts\" IN (0, 1)), \"created_by\" TEXT NULL, \"created_at\" INTEGER NOT NULL, \"archived_at\" INTEGER NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entries", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entries\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"kind\" TEXT NOT NULL, \"description\" TEXT NOT NULL DEFAULT '', \"category_id\" TEXT NULL, \"currency\" TEXT NOT NULL, \"amount_minor\" INTEGER NOT NULL, \"entry_date\" INTEGER NOT NULL, \"split_kind\" TEXT NOT NULL, \"fx_rate\" REAL NULL, \"fx_source\" TEXT NULL, \"fx_at\" INTEGER NULL, \"notes\" TEXT NULL, \"created_by\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"seq\" INTEGER NULL, \"deleted_at\" INTEGER NULL, \"client_key\" TEXT NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entries_fts", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid);" - } - ] - }, - { - "name": "entries_fts_insert", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" - } - ] - }, - { - "name": "entries_fts_delete", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);END" - } - ] - }, - { - "name": "entries_fts_update", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" - } - ] - }, - { - "name": "profiles", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"profiles\" (\"id\" TEXT NOT NULL, \"display_name\" TEXT NULL, \"upi_vpa\" TEXT NULL, \"updated_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "members", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"members\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"profile_id\" TEXT NULL, \"display_name\" TEXT NOT NULL, \"joined_at\" INTEGER NOT NULL, \"left_at\" INTEGER NULL, \"upi_vpa\" TEXT NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "categories", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"categories\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"icon\" TEXT NOT NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entry_payers", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_payers\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" - } - ] - }, - { - "name": "entry_shares", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_shares\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, \"weight_micros\" INTEGER NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" - } - ] - }, - { - "name": "group_events", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"group_events\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"actor_id\" TEXT NULL REFERENCES members (id) ON DELETE CASCADE, \"created_at\" INTEGER NOT NULL, \"kind\" TEXT NOT NULL, \"subject_id\" TEXT NULL, \"payload\" TEXT NOT NULL, \"seq\" INTEGER NULL, \"ordinal\" INTEGER NULL, \"is_provisional\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_provisional\" IN (0, 1)), PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entry_conflicts", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_conflicts\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"attempted\" TEXT NOT NULL, \"base_seq\" INTEGER NULL, \"rejected_at\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\"));" - } - ] - }, - { - "name": "fx_rates", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"fx_rates\" (\"as_of\" TEXT NOT NULL, \"currency\" TEXT NOT NULL, \"rate\" REAL NOT NULL, \"source\" TEXT NOT NULL, PRIMARY KEY (\"as_of\", \"currency\"));" - } - ] - }, - { - "name": "outbox", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"outbox\" (\"id\" TEXT NOT NULL, \"operation\" TEXT NOT NULL, \"target_id\" TEXT NOT NULL, \"revision\" TEXT NOT NULL DEFAULT '', \"payload\" TEXT NOT NULL DEFAULT '{}', \"created_at\" INTEGER NOT NULL, \"attempts\" INTEGER NOT NULL DEFAULT 0, \"next_attempt_at\" INTEGER NULL, \"last_error\" TEXT NULL, \"dead_lettered_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "group_cursors", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"group_cursors\" (\"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"seq\" INTEGER NOT NULL DEFAULT 0, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"group_id\"));" - } - ] - }, - { - "name": "feed_cursors", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"feed_cursors\" (\"feed\" TEXT NOT NULL, \"cursor\" INTEGER NULL, \"cursor_id\" TEXT NULL, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"feed\"));" - } - ] - }, - { - "name": "sync_leases", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_leases\" (\"name\" TEXT NOT NULL, \"owner\" TEXT NOT NULL, \"expires_at\" INTEGER NOT NULL, PRIMARY KEY (\"name\"));" - } - ] - }, - { - "name": "sync_sessions", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_sessions\" (\"id\" TEXT NOT NULL, \"epoch\" TEXT NOT NULL, \"enabled\" INTEGER NOT NULL CHECK (\"enabled\" IN (0, 1)), PRIMARY KEY (\"id\"));" - } - ] - } - ] -} \ No newline at end of file diff --git a/drift_schemas/drift_schema_v5.json b/drift_schemas/drift_schema_v5.json deleted file mode 100644 index 41cfff3..0000000 --- a/drift_schemas/drift_schema_v5.json +++ /dev/null @@ -1,1755 +0,0 @@ -{ - "_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": "currencies", - "was_declared_in_moor": false, - "columns": [ - { - "name": "code", - "getter_name": "code", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "allowed-lengths": { - "min": 3, - "max": 3 - } - } - ] - }, - { - "name": "exponent", - "getter_name": "exponent", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "symbol", - "getter_name": "symbol", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "name", - "getter_name": "name", - "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": [ - "code" - ] - } - }, - { - "id": 1, - "references": [ - 0 - ], - "type": "table", - "data": { - "name": "groups", - "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": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "default_currency", - "getter_name": "defaultCurrency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES currencies (code)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES currencies (code)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "currencies", - "column": "code" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "is_direct", - "getter_name": "isDirect", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"is_direct\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"is_direct\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "simplify_debts", - "getter_name": "simplifyDebts", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"simplify_debts\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"simplify_debts\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('1')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "archived_at", - "getter_name": "archivedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 2, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "entries", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "kind", - "getter_name": "kind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(EntryKind.values,EntryKind.unknownDefaultOpenApi)", - "dart_type_name": "EntryKind" - } - }, - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'\\'')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "category_id", - "getter_name": "categoryId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "currency", - "getter_name": "currency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "entry_date", - "getter_name": "entryDate", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const CalendarDateConverter()", - "dart_type_name": "DateTime" - } - }, - { - "name": "split_kind", - "getter_name": "splitKind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(SplitKind.values,SplitKind.unknownDefaultOpenApi)", - "dart_type_name": "SplitKind" - } - }, - { - "name": "fx_rate", - "getter_name": "fxRate", - "moor_type": "double", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "fx_source", - "getter_name": "fxSource", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "fx_at", - "getter_name": "fxAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "notes", - "getter_name": "notes", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "deleted_at", - "getter_name": "deletedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "client_key", - "getter_name": "clientKey", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 3, - "references": [ - 2 - ], - "type": "table", - "data": { - "name": "entries_fts", - "was_declared_in_moor": true, - "columns": [ - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": "", - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "notes", - "getter_name": "notes", - "moor_type": "string", - "nullable": false, - "customConstraints": "", - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": true, - "create_virtual_stmt": "CREATE VIRTUAL TABLE \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid)", - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 4, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_insert", - "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" - } - }, - { - "id": 5, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_delete", - "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\nEND;" - } - }, - { - "id": 6, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_update", - "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" - } - }, - { - "id": 7, - "references": [], - "type": "table", - "data": { - "name": "profiles", - "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": "display_name", - "getter_name": "displayName", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "upi_vpa", - "getter_name": "upiVpa", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "updated_at", - "getter_name": "updatedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 8, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "members", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "profile_id", - "getter_name": "profileId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "display_name", - "getter_name": "displayName", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "joined_at", - "getter_name": "joinedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "left_at", - "getter_name": "leftAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "upi_vpa", - "getter_name": "upiVpa", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 9, - "references": [], - "type": "table", - "data": { - "name": "categories", - "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": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "icon", - "getter_name": "icon", - "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": [ - "id" - ] - } - }, - { - "id": 10, - "references": [ - 2, - 8 - ], - "type": "table", - "data": { - "name": "entry_payers", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "member_id", - "getter_name": "memberId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id", - "member_id" - ] - } - }, - { - "id": 11, - "references": [ - 2, - 8 - ], - "type": "table", - "data": { - "name": "entry_shares", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "member_id", - "getter_name": "memberId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "weight_micros", - "getter_name": "weightMicros", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id", - "member_id" - ] - } - }, - { - "id": 12, - "references": [ - 1, - 8 - ], - "type": "table", - "data": { - "name": "group_events", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "actor_id", - "getter_name": "actorId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "kind", - "getter_name": "kind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(EventKind.values,EventKind.unknownDefaultOpenApi)", - "dart_type_name": "EventKind" - } - }, - { - "name": "subject_id", - "getter_name": "subjectId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "payload", - "getter_name": "payload", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const JsonMapConverter()", - "dart_type_name": "Map" - } - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "ordinal", - "getter_name": "ordinal", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "is_provisional", - "getter_name": "isProvisional", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"is_provisional\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"is_provisional\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 13, - "references": [ - 2, - 1 - ], - "type": "table", - "data": { - "name": "entry_conflicts", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "attempted", - "getter_name": "attempted", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "base_seq", - "getter_name": "baseSeq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "rejected_at", - "getter_name": "rejectedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id" - ] - } - }, - { - "id": 14, - "references": [], - "type": "table", - "data": { - "name": "fx_rates", - "was_declared_in_moor": false, - "columns": [ - { - "name": "as_of", - "getter_name": "asOf", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "currency", - "getter_name": "currency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "rate", - "getter_name": "rate", - "moor_type": "double", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "source", - "getter_name": "source", - "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": [ - "as_of", - "currency" - ] - } - }, - { - "id": 15, - "references": [], - "type": "table", - "data": { - "name": "outbox", - "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": "target", - "getter_name": "target", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(OutboxTarget.values)", - "dart_type_name": "OutboxTarget" - } - }, - { - "name": "target_id", - "getter_name": "targetId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "revision", - "getter_name": "revision", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "attempts", - "getter_name": "attempts", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "next_attempt_at", - "getter_name": "nextAttemptAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_error", - "getter_name": "lastError", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "dead_lettered_at", - "getter_name": "deadLetteredAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 16, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "group_cursors", - "was_declared_in_moor": false, - "columns": [ - { - "name": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_synced_at", - "getter_name": "lastSyncedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "group_id" - ] - } - }, - { - "id": 17, - "references": [], - "type": "table", - "data": { - "name": "feed_cursors", - "was_declared_in_moor": false, - "columns": [ - { - "name": "feed", - "getter_name": "feed", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "cursor", - "getter_name": "cursor", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "cursor_id", - "getter_name": "cursorId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_synced_at", - "getter_name": "lastSyncedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "feed" - ] - } - }, - { - "id": 18, - "references": [], - "type": "table", - "data": { - "name": "sync_leases", - "was_declared_in_moor": false, - "columns": [ - { - "name": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "owner", - "getter_name": "owner", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "expires_at", - "getter_name": "expiresAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "name" - ] - } - }, - { - "id": 19, - "references": [], - "type": "table", - "data": { - "name": "sync_sessions", - "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": "epoch", - "getter_name": "epoch", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "enabled", - "getter_name": "enabled", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"enabled\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"enabled\" IN (0, 1))" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - } - ], - "fixed_sql": [ - { - "name": "currencies", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"currencies\" (\"code\" TEXT NOT NULL, \"exponent\" INTEGER NOT NULL, \"symbol\" TEXT NULL, \"name\" TEXT NOT NULL, PRIMARY KEY (\"code\"));" - } - ] - }, - { - "name": "groups", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"groups\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"default_currency\" TEXT NOT NULL REFERENCES currencies (code), \"is_direct\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_direct\" IN (0, 1)), \"simplify_debts\" INTEGER NOT NULL DEFAULT 1 CHECK (\"simplify_debts\" IN (0, 1)), \"created_by\" TEXT NULL, \"created_at\" INTEGER NOT NULL, \"archived_at\" INTEGER NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entries", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entries\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"kind\" TEXT NOT NULL, \"description\" TEXT NOT NULL DEFAULT '', \"category_id\" TEXT NULL, \"currency\" TEXT NOT NULL, \"amount_minor\" INTEGER NOT NULL, \"entry_date\" TEXT NOT NULL, \"split_kind\" TEXT NOT NULL, \"fx_rate\" REAL NULL, \"fx_source\" TEXT NULL, \"fx_at\" INTEGER NULL, \"notes\" TEXT NULL, \"created_by\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"seq\" INTEGER NULL, \"deleted_at\" INTEGER NULL, \"client_key\" TEXT NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entries_fts", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid);" - } - ] - }, - { - "name": "entries_fts_insert", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" - } - ] - }, - { - "name": "entries_fts_delete", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);END" - } - ] - }, - { - "name": "entries_fts_update", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" - } - ] - }, - { - "name": "profiles", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"profiles\" (\"id\" TEXT NOT NULL, \"display_name\" TEXT NULL, \"upi_vpa\" TEXT NULL, \"updated_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "members", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"members\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"profile_id\" TEXT NULL, \"display_name\" TEXT NOT NULL, \"joined_at\" INTEGER NOT NULL, \"left_at\" INTEGER NULL, \"upi_vpa\" TEXT NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "categories", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"categories\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"icon\" TEXT NOT NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entry_payers", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_payers\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" - } - ] - }, - { - "name": "entry_shares", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_shares\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, \"weight_micros\" INTEGER NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" - } - ] - }, - { - "name": "group_events", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"group_events\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"actor_id\" TEXT NULL REFERENCES members (id) ON DELETE CASCADE, \"created_at\" INTEGER NOT NULL, \"kind\" TEXT NOT NULL, \"subject_id\" TEXT NULL, \"payload\" TEXT NOT NULL, \"seq\" INTEGER NULL, \"ordinal\" INTEGER NULL, \"is_provisional\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_provisional\" IN (0, 1)), PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entry_conflicts", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_conflicts\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"attempted\" TEXT NOT NULL, \"base_seq\" INTEGER NULL, \"rejected_at\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\"));" - } - ] - }, - { - "name": "fx_rates", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"fx_rates\" (\"as_of\" TEXT NOT NULL, \"currency\" TEXT NOT NULL, \"rate\" REAL NOT NULL, \"source\" TEXT NOT NULL, PRIMARY KEY (\"as_of\", \"currency\"));" - } - ] - }, - { - "name": "outbox", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"outbox\" (\"id\" TEXT NOT NULL, \"target\" TEXT NOT NULL, \"target_id\" TEXT NOT NULL, \"revision\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"attempts\" INTEGER NOT NULL DEFAULT 0, \"next_attempt_at\" INTEGER NULL, \"last_error\" TEXT NULL, \"dead_lettered_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "group_cursors", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"group_cursors\" (\"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"seq\" INTEGER NOT NULL DEFAULT 0, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"group_id\"));" - } - ] - }, - { - "name": "feed_cursors", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"feed_cursors\" (\"feed\" TEXT NOT NULL, \"cursor\" INTEGER NULL, \"cursor_id\" TEXT NULL, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"feed\"));" - } - ] - }, - { - "name": "sync_leases", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_leases\" (\"name\" TEXT NOT NULL, \"owner\" TEXT NOT NULL, \"expires_at\" INTEGER NOT NULL, PRIMARY KEY (\"name\"));" - } - ] - }, - { - "name": "sync_sessions", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_sessions\" (\"id\" TEXT NOT NULL, \"epoch\" TEXT NOT NULL, \"enabled\" INTEGER NOT NULL CHECK (\"enabled\" IN (0, 1)), PRIMARY KEY (\"id\"));" - } - ] - } - ] -} \ No newline at end of file diff --git a/drift_schemas/drift_schema_v6.json b/drift_schemas/drift_schema_v6.json deleted file mode 100644 index e678d50..0000000 --- a/drift_schemas/drift_schema_v6.json +++ /dev/null @@ -1,1766 +0,0 @@ -{ - "_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": "currencies", - "was_declared_in_moor": false, - "columns": [ - { - "name": "code", - "getter_name": "code", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "allowed-lengths": { - "min": 3, - "max": 3 - } - } - ] - }, - { - "name": "exponent", - "getter_name": "exponent", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "symbol", - "getter_name": "symbol", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "name", - "getter_name": "name", - "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": [ - "code" - ] - } - }, - { - "id": 1, - "references": [ - 0 - ], - "type": "table", - "data": { - "name": "groups", - "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": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "default_currency", - "getter_name": "defaultCurrency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES currencies (code)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES currencies (code)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "currencies", - "column": "code" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "is_direct", - "getter_name": "isDirect", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"is_direct\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"is_direct\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "simplify_debts", - "getter_name": "simplifyDebts", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"simplify_debts\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"simplify_debts\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('1')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "archived_at", - "getter_name": "archivedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 2, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "entries", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "kind", - "getter_name": "kind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(EntryKind.values,EntryKind.unknownDefaultOpenApi)", - "dart_type_name": "EntryKind" - } - }, - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'\\'')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "category_id", - "getter_name": "categoryId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "currency", - "getter_name": "currency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "entry_date", - "getter_name": "entryDate", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const CalendarDateConverter()", - "dart_type_name": "DateTime" - } - }, - { - "name": "occurred_at", - "getter_name": "occurredAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "time_zone", - "getter_name": "timeZone", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "split_kind", - "getter_name": "splitKind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(SplitKind.values,SplitKind.unknownDefaultOpenApi)", - "dart_type_name": "SplitKind" - } - }, - { - "name": "fx_rate", - "getter_name": "fxRate", - "moor_type": "double", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "fx_source", - "getter_name": "fxSource", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "fx_at", - "getter_name": "fxAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "notes", - "getter_name": "notes", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "deleted_at", - "getter_name": "deletedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "client_key", - "getter_name": "clientKey", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 3, - "references": [ - 2 - ], - "type": "table", - "data": { - "name": "entries_fts", - "was_declared_in_moor": true, - "columns": [ - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": "", - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "notes", - "getter_name": "notes", - "moor_type": "string", - "nullable": false, - "customConstraints": "", - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": true, - "create_virtual_stmt": "CREATE VIRTUAL TABLE \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid)", - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 4, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_insert", - "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" - } - }, - { - "id": 5, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_delete", - "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\nEND;" - } - }, - { - "id": 6, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_update", - "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" - } - }, - { - "id": 7, - "references": [], - "type": "table", - "data": { - "name": "profiles", - "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": "display_name", - "getter_name": "displayName", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "upi_vpa", - "getter_name": "upiVpa", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "updated_at", - "getter_name": "updatedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 8, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "members", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "profile_id", - "getter_name": "profileId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "display_name", - "getter_name": "displayName", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "joined_at", - "getter_name": "joinedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "left_at", - "getter_name": "leftAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "upi_vpa", - "getter_name": "upiVpa", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 9, - "references": [], - "type": "table", - "data": { - "name": "categories", - "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": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "icon", - "getter_name": "icon", - "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": [ - "id" - ] - } - }, - { - "id": 10, - "references": [ - 2, - 8 - ], - "type": "table", - "data": { - "name": "entry_payers", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "member_id", - "getter_name": "memberId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id", - "member_id" - ] - } - }, - { - "id": 11, - "references": [ - 2, - 8 - ], - "type": "table", - "data": { - "name": "entry_shares", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "member_id", - "getter_name": "memberId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "weight_micros", - "getter_name": "weightMicros", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id", - "member_id" - ] - } - }, - { - "id": 12, - "references": [ - 1, - 8 - ], - "type": "table", - "data": { - "name": "group_events", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "actor_id", - "getter_name": "actorId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "kind", - "getter_name": "kind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(EventKind.values,EventKind.unknownDefaultOpenApi)", - "dart_type_name": "EventKind" - } - }, - { - "name": "subject_id", - "getter_name": "subjectId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "payload", - "getter_name": "payload", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const JsonMapConverter()", - "dart_type_name": "Map" - } - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "ordinal", - "getter_name": "ordinal", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "is_provisional", - "getter_name": "isProvisional", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"is_provisional\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"is_provisional\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 13, - "references": [ - 2, - 1 - ], - "type": "table", - "data": { - "name": "entry_conflicts", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "attempted", - "getter_name": "attempted", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "base_seq", - "getter_name": "baseSeq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "rejected_at", - "getter_name": "rejectedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id" - ] - } - }, - { - "id": 14, - "references": [], - "type": "table", - "data": { - "name": "fx_rates", - "was_declared_in_moor": false, - "columns": [ - { - "name": "as_of", - "getter_name": "asOf", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "currency", - "getter_name": "currency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "rate", - "getter_name": "rate", - "moor_type": "double", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "source", - "getter_name": "source", - "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": [ - "as_of", - "currency" - ] - } - }, - { - "id": 15, - "references": [], - "type": "table", - "data": { - "name": "outbox", - "was_declared_in_moor": false, - "columns": [ - { - "name": "target", - "getter_name": "target", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(OutboxTarget.values)", - "dart_type_name": "OutboxTarget" - } - }, - { - "name": "target_id", - "getter_name": "targetId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "revision", - "getter_name": "revision", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "attempts", - "getter_name": "attempts", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "next_attempt_at", - "getter_name": "nextAttemptAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_error", - "getter_name": "lastError", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "dead_lettered_at", - "getter_name": "deadLetteredAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "target", - "target_id" - ] - } - }, - { - "id": 16, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "group_cursors", - "was_declared_in_moor": false, - "columns": [ - { - "name": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_synced_at", - "getter_name": "lastSyncedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "group_id" - ] - } - }, - { - "id": 17, - "references": [], - "type": "table", - "data": { - "name": "feed_cursors", - "was_declared_in_moor": false, - "columns": [ - { - "name": "feed", - "getter_name": "feed", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "cursor", - "getter_name": "cursor", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "cursor_id", - "getter_name": "cursorId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_synced_at", - "getter_name": "lastSyncedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "feed" - ] - } - }, - { - "id": 18, - "references": [], - "type": "table", - "data": { - "name": "sync_leases", - "was_declared_in_moor": false, - "columns": [ - { - "name": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "owner", - "getter_name": "owner", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "expires_at", - "getter_name": "expiresAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "name" - ] - } - }, - { - "id": 19, - "references": [], - "type": "table", - "data": { - "name": "sync_sessions", - "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": "epoch", - "getter_name": "epoch", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "enabled", - "getter_name": "enabled", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"enabled\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"enabled\" IN (0, 1))" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - } - ], - "fixed_sql": [ - { - "name": "currencies", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"currencies\" (\"code\" TEXT NOT NULL, \"exponent\" INTEGER NOT NULL, \"symbol\" TEXT NULL, \"name\" TEXT NOT NULL, PRIMARY KEY (\"code\"));" - } - ] - }, - { - "name": "groups", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"groups\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"default_currency\" TEXT NOT NULL REFERENCES currencies (code), \"is_direct\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_direct\" IN (0, 1)), \"simplify_debts\" INTEGER NOT NULL DEFAULT 1 CHECK (\"simplify_debts\" IN (0, 1)), \"created_by\" TEXT NULL, \"created_at\" INTEGER NOT NULL, \"archived_at\" INTEGER NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entries", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entries\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"kind\" TEXT NOT NULL, \"description\" TEXT NOT NULL DEFAULT '', \"category_id\" TEXT NULL, \"currency\" TEXT NOT NULL, \"amount_minor\" INTEGER NOT NULL, \"entry_date\" TEXT NOT NULL, \"occurred_at\" INTEGER NULL, \"time_zone\" TEXT NULL, \"split_kind\" TEXT NOT NULL, \"fx_rate\" REAL NULL, \"fx_source\" TEXT NULL, \"fx_at\" INTEGER NULL, \"notes\" TEXT NULL, \"created_by\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"seq\" INTEGER NULL, \"deleted_at\" INTEGER NULL, \"client_key\" TEXT NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entries_fts", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid);" - } - ] - }, - { - "name": "entries_fts_insert", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" - } - ] - }, - { - "name": "entries_fts_delete", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);END" - } - ] - }, - { - "name": "entries_fts_update", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" - } - ] - }, - { - "name": "profiles", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"profiles\" (\"id\" TEXT NOT NULL, \"display_name\" TEXT NULL, \"upi_vpa\" TEXT NULL, \"updated_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "members", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"members\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"profile_id\" TEXT NULL, \"display_name\" TEXT NOT NULL, \"joined_at\" INTEGER NOT NULL, \"left_at\" INTEGER NULL, \"upi_vpa\" TEXT NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "categories", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"categories\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"icon\" TEXT NOT NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entry_payers", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_payers\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" - } - ] - }, - { - "name": "entry_shares", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_shares\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, \"weight_micros\" INTEGER NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" - } - ] - }, - { - "name": "group_events", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"group_events\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"actor_id\" TEXT NULL REFERENCES members (id) ON DELETE CASCADE, \"created_at\" INTEGER NOT NULL, \"kind\" TEXT NOT NULL, \"subject_id\" TEXT NULL, \"payload\" TEXT NOT NULL, \"seq\" INTEGER NULL, \"ordinal\" INTEGER NULL, \"is_provisional\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_provisional\" IN (0, 1)), PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entry_conflicts", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_conflicts\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"attempted\" TEXT NOT NULL, \"base_seq\" INTEGER NULL, \"rejected_at\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\"));" - } - ] - }, - { - "name": "fx_rates", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"fx_rates\" (\"as_of\" TEXT NOT NULL, \"currency\" TEXT NOT NULL, \"rate\" REAL NOT NULL, \"source\" TEXT NOT NULL, PRIMARY KEY (\"as_of\", \"currency\"));" - } - ] - }, - { - "name": "outbox", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"outbox\" (\"target\" TEXT NOT NULL, \"target_id\" TEXT NOT NULL, \"revision\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"attempts\" INTEGER NOT NULL DEFAULT 0, \"next_attempt_at\" INTEGER NULL, \"last_error\" TEXT NULL, \"dead_lettered_at\" INTEGER NULL, PRIMARY KEY (\"target\", \"target_id\"));" - } - ] - }, - { - "name": "group_cursors", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"group_cursors\" (\"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"seq\" INTEGER NOT NULL DEFAULT 0, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"group_id\"));" - } - ] - }, - { - "name": "feed_cursors", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"feed_cursors\" (\"feed\" TEXT NOT NULL, \"cursor\" INTEGER NULL, \"cursor_id\" TEXT NULL, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"feed\"));" - } - ] - }, - { - "name": "sync_leases", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_leases\" (\"name\" TEXT NOT NULL, \"owner\" TEXT NOT NULL, \"expires_at\" INTEGER NOT NULL, PRIMARY KEY (\"name\"));" - } - ] - }, - { - "name": "sync_sessions", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_sessions\" (\"id\" TEXT NOT NULL, \"epoch\" TEXT NOT NULL, \"enabled\" INTEGER NOT NULL CHECK (\"enabled\" IN (0, 1)), PRIMARY KEY (\"id\"));" - } - ] - } - ] -} \ No newline at end of file diff --git a/drift_schemas/drift_schema_v7.json b/drift_schemas/drift_schema_v7.json deleted file mode 100644 index faf7930..0000000 --- a/drift_schemas/drift_schema_v7.json +++ /dev/null @@ -1,1802 +0,0 @@ -{ - "_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": "currencies", - "was_declared_in_moor": false, - "columns": [ - { - "name": "code", - "getter_name": "code", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "allowed-lengths": { - "min": 3, - "max": 3 - } - } - ] - }, - { - "name": "exponent", - "getter_name": "exponent", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "symbol", - "getter_name": "symbol", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "name", - "getter_name": "name", - "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": [ - "code" - ] - } - }, - { - "id": 1, - "references": [ - 0 - ], - "type": "table", - "data": { - "name": "groups", - "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": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "default_currency", - "getter_name": "defaultCurrency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES currencies (code)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES currencies (code)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "currencies", - "column": "code" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "is_direct", - "getter_name": "isDirect", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"is_direct\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"is_direct\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "simplify_debts", - "getter_name": "simplifyDebts", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"simplify_debts\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"simplify_debts\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('1')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "archived_at", - "getter_name": "archivedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 2, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "entries", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "kind", - "getter_name": "kind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(EntryKind.values,EntryKind.unknownDefaultOpenApi)", - "dart_type_name": "EntryKind" - } - }, - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'\\'')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "category_id", - "getter_name": "categoryId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "currency", - "getter_name": "currency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "entry_date", - "getter_name": "entryDate", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const CalendarDateConverter()", - "dart_type_name": "DateTime" - } - }, - { - "name": "occurred_at", - "getter_name": "occurredAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "time_zone", - "getter_name": "timeZone", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "split_kind", - "getter_name": "splitKind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(SplitKind.values,SplitKind.unknownDefaultOpenApi)", - "dart_type_name": "SplitKind" - } - }, - { - "name": "fx_rate", - "getter_name": "fxRate", - "moor_type": "double", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "fx_source", - "getter_name": "fxSource", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "fx_at", - "getter_name": "fxAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "notes", - "getter_name": "notes", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "deleted_at", - "getter_name": "deletedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "client_key", - "getter_name": "clientKey", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 3, - "references": [ - 2 - ], - "type": "table", - "data": { - "name": "entries_fts", - "was_declared_in_moor": true, - "columns": [ - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": "", - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "notes", - "getter_name": "notes", - "moor_type": "string", - "nullable": false, - "customConstraints": "", - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": true, - "create_virtual_stmt": "CREATE VIRTUAL TABLE \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid)", - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 4, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_insert", - "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" - } - }, - { - "id": 5, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_delete", - "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\nEND;" - } - }, - { - "id": 6, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_update", - "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" - } - }, - { - "id": 7, - "references": [], - "type": "table", - "data": { - "name": "profiles", - "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": "display_name", - "getter_name": "displayName", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "upi_vpa", - "getter_name": "upiVpa", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "updated_at", - "getter_name": "updatedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 8, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "members", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "profile_id", - "getter_name": "profileId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "display_name", - "getter_name": "displayName", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "joined_at", - "getter_name": "joinedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "left_at", - "getter_name": "leftAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "upi_vpa", - "getter_name": "upiVpa", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 9, - "references": [], - "type": "table", - "data": { - "name": "categories", - "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": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "icon", - "getter_name": "icon", - "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": [ - "id" - ] - } - }, - { - "id": 10, - "references": [ - 2, - 8 - ], - "type": "table", - "data": { - "name": "entry_payers", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "member_id", - "getter_name": "memberId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id", - "member_id" - ] - } - }, - { - "id": 11, - "references": [ - 2, - 8 - ], - "type": "table", - "data": { - "name": "entry_shares", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "member_id", - "getter_name": "memberId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "weight_micros", - "getter_name": "weightMicros", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id", - "member_id" - ] - } - }, - { - "id": 12, - "references": [ - 1, - 8 - ], - "type": "table", - "data": { - "name": "group_events", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "actor_id", - "getter_name": "actorId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "kind", - "getter_name": "kind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(EventKind.values,EventKind.unknownDefaultOpenApi)", - "dart_type_name": "EventKind" - } - }, - { - "name": "subject_id", - "getter_name": "subjectId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "entry", - "getter_name": "entry", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(EntrySnapshot.fromJson)", - "dart_type_name": "EntrySnapshot" - } - }, - { - "name": "member", - "getter_name": "member", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(MemberEventPayload.fromJson)", - "dart_type_name": "MemberEventPayload" - } - }, - { - "name": "group", - "getter_name": "group", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(GroupEventPayload.fromJson)", - "dart_type_name": "GroupEventPayload" - } - }, - { - "name": "link", - "getter_name": "link", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(LinkEventPayload.fromJson)", - "dart_type_name": "LinkEventPayload" - } - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "ordinal", - "getter_name": "ordinal", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "is_provisional", - "getter_name": "isProvisional", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"is_provisional\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"is_provisional\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 13, - "references": [ - 2, - 1 - ], - "type": "table", - "data": { - "name": "entry_conflicts", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "attempted", - "getter_name": "attempted", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(EntrySnapshot.fromJson)", - "dart_type_name": "EntrySnapshot" - } - }, - { - "name": "base_seq", - "getter_name": "baseSeq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "rejected_at", - "getter_name": "rejectedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id" - ] - } - }, - { - "id": 14, - "references": [], - "type": "table", - "data": { - "name": "fx_rates", - "was_declared_in_moor": false, - "columns": [ - { - "name": "as_of", - "getter_name": "asOf", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "currency", - "getter_name": "currency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "rate", - "getter_name": "rate", - "moor_type": "double", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "source", - "getter_name": "source", - "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": [ - "as_of", - "currency" - ] - } - }, - { - "id": 15, - "references": [], - "type": "table", - "data": { - "name": "outbox", - "was_declared_in_moor": false, - "columns": [ - { - "name": "target", - "getter_name": "target", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(OutboxTarget.values)", - "dart_type_name": "OutboxTarget" - } - }, - { - "name": "target_id", - "getter_name": "targetId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "revision", - "getter_name": "revision", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "attempts", - "getter_name": "attempts", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "next_attempt_at", - "getter_name": "nextAttemptAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_error", - "getter_name": "lastError", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "dead_lettered_at", - "getter_name": "deadLetteredAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "target", - "target_id" - ] - } - }, - { - "id": 16, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "group_cursors", - "was_declared_in_moor": false, - "columns": [ - { - "name": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_synced_at", - "getter_name": "lastSyncedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "group_id" - ] - } - }, - { - "id": 17, - "references": [], - "type": "table", - "data": { - "name": "feed_cursors", - "was_declared_in_moor": false, - "columns": [ - { - "name": "feed", - "getter_name": "feed", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "cursor", - "getter_name": "cursor", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_synced_at", - "getter_name": "lastSyncedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "feed" - ] - } - }, - { - "id": 18, - "references": [], - "type": "table", - "data": { - "name": "sync_leases", - "was_declared_in_moor": false, - "columns": [ - { - "name": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "owner", - "getter_name": "owner", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "expires_at", - "getter_name": "expiresAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "name" - ] - } - }, - { - "id": 19, - "references": [], - "type": "table", - "data": { - "name": "sync_sessions", - "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": "epoch", - "getter_name": "epoch", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "enabled", - "getter_name": "enabled", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"enabled\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"enabled\" IN (0, 1))" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - } - ], - "fixed_sql": [ - { - "name": "currencies", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"currencies\" (\"code\" TEXT NOT NULL, \"exponent\" INTEGER NOT NULL, \"symbol\" TEXT NULL, \"name\" TEXT NOT NULL, PRIMARY KEY (\"code\"));" - } - ] - }, - { - "name": "groups", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"groups\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"default_currency\" TEXT NOT NULL REFERENCES currencies (code), \"is_direct\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_direct\" IN (0, 1)), \"simplify_debts\" INTEGER NOT NULL DEFAULT 1 CHECK (\"simplify_debts\" IN (0, 1)), \"created_by\" TEXT NULL, \"created_at\" INTEGER NOT NULL, \"archived_at\" INTEGER NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entries", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entries\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"kind\" TEXT NOT NULL, \"description\" TEXT NOT NULL DEFAULT '', \"category_id\" TEXT NULL, \"currency\" TEXT NOT NULL, \"amount_minor\" INTEGER NOT NULL, \"entry_date\" TEXT NOT NULL, \"occurred_at\" INTEGER NULL, \"time_zone\" TEXT NULL, \"split_kind\" TEXT NOT NULL, \"fx_rate\" REAL NULL, \"fx_source\" TEXT NULL, \"fx_at\" INTEGER NULL, \"notes\" TEXT NULL, \"created_by\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"seq\" INTEGER NULL, \"deleted_at\" INTEGER NULL, \"client_key\" TEXT NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entries_fts", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid);" - } - ] - }, - { - "name": "entries_fts_insert", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" - } - ] - }, - { - "name": "entries_fts_delete", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);END" - } - ] - }, - { - "name": "entries_fts_update", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" - } - ] - }, - { - "name": "profiles", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"profiles\" (\"id\" TEXT NOT NULL, \"display_name\" TEXT NULL, \"upi_vpa\" TEXT NULL, \"updated_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "members", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"members\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"profile_id\" TEXT NULL, \"display_name\" TEXT NOT NULL, \"joined_at\" INTEGER NOT NULL, \"left_at\" INTEGER NULL, \"upi_vpa\" TEXT NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "categories", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"categories\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"icon\" TEXT NOT NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entry_payers", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_payers\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" - } - ] - }, - { - "name": "entry_shares", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_shares\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, \"weight_micros\" INTEGER NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" - } - ] - }, - { - "name": "group_events", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"group_events\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"actor_id\" TEXT NULL REFERENCES members (id) ON DELETE CASCADE, \"created_at\" INTEGER NOT NULL, \"kind\" TEXT NOT NULL, \"subject_id\" TEXT NULL, \"entry\" TEXT NULL, \"member\" TEXT NULL, \"group\" TEXT NULL, \"link\" TEXT NULL, \"seq\" INTEGER NULL, \"ordinal\" INTEGER NULL, \"is_provisional\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_provisional\" IN (0, 1)), PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entry_conflicts", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_conflicts\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"attempted\" TEXT NOT NULL, \"base_seq\" INTEGER NULL, \"rejected_at\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\"));" - } - ] - }, - { - "name": "fx_rates", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"fx_rates\" (\"as_of\" TEXT NOT NULL, \"currency\" TEXT NOT NULL, \"rate\" REAL NOT NULL, \"source\" TEXT NOT NULL, PRIMARY KEY (\"as_of\", \"currency\"));" - } - ] - }, - { - "name": "outbox", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"outbox\" (\"target\" TEXT NOT NULL, \"target_id\" TEXT NOT NULL, \"revision\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"attempts\" INTEGER NOT NULL DEFAULT 0, \"next_attempt_at\" INTEGER NULL, \"last_error\" TEXT NULL, \"dead_lettered_at\" INTEGER NULL, PRIMARY KEY (\"target\", \"target_id\"));" - } - ] - }, - { - "name": "group_cursors", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"group_cursors\" (\"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"seq\" INTEGER NOT NULL DEFAULT 0, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"group_id\"));" - } - ] - }, - { - "name": "feed_cursors", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"feed_cursors\" (\"feed\" TEXT NOT NULL, \"cursor\" TEXT NULL, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"feed\"));" - } - ] - }, - { - "name": "sync_leases", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_leases\" (\"name\" TEXT NOT NULL, \"owner\" TEXT NOT NULL, \"expires_at\" INTEGER NOT NULL, PRIMARY KEY (\"name\"));" - } - ] - }, - { - "name": "sync_sessions", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_sessions\" (\"id\" TEXT NOT NULL, \"epoch\" TEXT NOT NULL, \"enabled\" INTEGER NOT NULL CHECK (\"enabled\" IN (0, 1)), PRIMARY KEY (\"id\"));" - } - ] - } - ] -} \ No newline at end of file diff --git a/drift_schemas/drift_schema_v8.json b/drift_schemas/drift_schema_v8.json deleted file mode 100644 index 97b5f5b..0000000 --- a/drift_schemas/drift_schema_v8.json +++ /dev/null @@ -1,1792 +0,0 @@ -{ - "_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": "currencies", - "was_declared_in_moor": false, - "columns": [ - { - "name": "code", - "getter_name": "code", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "allowed-lengths": { - "min": 3, - "max": 3 - } - } - ] - }, - { - "name": "exponent", - "getter_name": "exponent", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "symbol", - "getter_name": "symbol", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "name", - "getter_name": "name", - "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": [ - "code" - ] - } - }, - { - "id": 1, - "references": [ - 0 - ], - "type": "table", - "data": { - "name": "groups", - "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": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "default_currency", - "getter_name": "defaultCurrency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES currencies (code)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES currencies (code)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "currencies", - "column": "code" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "is_direct", - "getter_name": "isDirect", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"is_direct\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"is_direct\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "simplify_debts", - "getter_name": "simplifyDebts", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"simplify_debts\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"simplify_debts\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('1')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "archived_at", - "getter_name": "archivedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 2, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "entries", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "kind", - "getter_name": "kind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(EntryKind.values,EntryKind.unknownDefaultOpenApi)", - "dart_type_name": "EntryKind" - } - }, - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'\\'')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "category_id", - "getter_name": "categoryId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "currency", - "getter_name": "currency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "entry_date", - "getter_name": "entryDate", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const CalendarDateConverter()", - "dart_type_name": "DateTime" - } - }, - { - "name": "occurred_at", - "getter_name": "occurredAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "time_zone", - "getter_name": "timeZone", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "split_kind", - "getter_name": "splitKind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(SplitKind.values,SplitKind.unknownDefaultOpenApi)", - "dart_type_name": "SplitKind" - } - }, - { - "name": "fx_rate", - "getter_name": "fxRate", - "moor_type": "double", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "fx_source", - "getter_name": "fxSource", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "fx_at", - "getter_name": "fxAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "notes", - "getter_name": "notes", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "deleted_at", - "getter_name": "deletedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 3, - "references": [ - 2 - ], - "type": "table", - "data": { - "name": "entries_fts", - "was_declared_in_moor": true, - "columns": [ - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": "", - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "notes", - "getter_name": "notes", - "moor_type": "string", - "nullable": false, - "customConstraints": "", - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": true, - "create_virtual_stmt": "CREATE VIRTUAL TABLE \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid)", - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 4, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_insert", - "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" - } - }, - { - "id": 5, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_delete", - "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\nEND;" - } - }, - { - "id": 6, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_update", - "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" - } - }, - { - "id": 7, - "references": [], - "type": "table", - "data": { - "name": "profiles", - "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": "display_name", - "getter_name": "displayName", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "upi_vpa", - "getter_name": "upiVpa", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "updated_at", - "getter_name": "updatedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 8, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "members", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "profile_id", - "getter_name": "profileId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "display_name", - "getter_name": "displayName", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "joined_at", - "getter_name": "joinedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "left_at", - "getter_name": "leftAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "upi_vpa", - "getter_name": "upiVpa", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 9, - "references": [], - "type": "table", - "data": { - "name": "categories", - "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": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "icon", - "getter_name": "icon", - "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": [ - "id" - ] - } - }, - { - "id": 10, - "references": [ - 2, - 8 - ], - "type": "table", - "data": { - "name": "entry_payers", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "member_id", - "getter_name": "memberId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id", - "member_id" - ] - } - }, - { - "id": 11, - "references": [ - 2, - 8 - ], - "type": "table", - "data": { - "name": "entry_shares", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "member_id", - "getter_name": "memberId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "weight_micros", - "getter_name": "weightMicros", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id", - "member_id" - ] - } - }, - { - "id": 12, - "references": [ - 1, - 8 - ], - "type": "table", - "data": { - "name": "group_events", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "actor_id", - "getter_name": "actorId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "kind", - "getter_name": "kind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(EventKind.values,EventKind.unknownDefaultOpenApi)", - "dart_type_name": "EventKind" - } - }, - { - "name": "subject_id", - "getter_name": "subjectId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "entry", - "getter_name": "entry", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(EntrySnapshot.fromJson)", - "dart_type_name": "EntrySnapshot" - } - }, - { - "name": "member", - "getter_name": "member", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(MemberEventPayload.fromJson)", - "dart_type_name": "MemberEventPayload" - } - }, - { - "name": "group", - "getter_name": "group", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(GroupEventPayload.fromJson)", - "dart_type_name": "GroupEventPayload" - } - }, - { - "name": "link", - "getter_name": "link", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(LinkEventPayload.fromJson)", - "dart_type_name": "LinkEventPayload" - } - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "ordinal", - "getter_name": "ordinal", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "is_provisional", - "getter_name": "isProvisional", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"is_provisional\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"is_provisional\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 13, - "references": [ - 2, - 1 - ], - "type": "table", - "data": { - "name": "entry_conflicts", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "attempted", - "getter_name": "attempted", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(EntrySnapshot.fromJson)", - "dart_type_name": "EntrySnapshot" - } - }, - { - "name": "base_seq", - "getter_name": "baseSeq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "rejected_at", - "getter_name": "rejectedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id" - ] - } - }, - { - "id": 14, - "references": [], - "type": "table", - "data": { - "name": "fx_rates", - "was_declared_in_moor": false, - "columns": [ - { - "name": "as_of", - "getter_name": "asOf", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "currency", - "getter_name": "currency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "rate", - "getter_name": "rate", - "moor_type": "double", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "source", - "getter_name": "source", - "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": [ - "as_of", - "currency" - ] - } - }, - { - "id": 15, - "references": [], - "type": "table", - "data": { - "name": "outbox", - "was_declared_in_moor": false, - "columns": [ - { - "name": "target", - "getter_name": "target", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(OutboxTarget.values)", - "dart_type_name": "OutboxTarget" - } - }, - { - "name": "target_id", - "getter_name": "targetId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "revision", - "getter_name": "revision", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "attempts", - "getter_name": "attempts", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "next_attempt_at", - "getter_name": "nextAttemptAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_error", - "getter_name": "lastError", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "dead_lettered_at", - "getter_name": "deadLetteredAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "target", - "target_id" - ] - } - }, - { - "id": 16, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "group_cursors", - "was_declared_in_moor": false, - "columns": [ - { - "name": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_synced_at", - "getter_name": "lastSyncedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "group_id" - ] - } - }, - { - "id": 17, - "references": [], - "type": "table", - "data": { - "name": "feed_cursors", - "was_declared_in_moor": false, - "columns": [ - { - "name": "feed", - "getter_name": "feed", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "cursor", - "getter_name": "cursor", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_synced_at", - "getter_name": "lastSyncedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "feed" - ] - } - }, - { - "id": 18, - "references": [], - "type": "table", - "data": { - "name": "sync_leases", - "was_declared_in_moor": false, - "columns": [ - { - "name": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "owner", - "getter_name": "owner", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "expires_at", - "getter_name": "expiresAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "name" - ] - } - }, - { - "id": 19, - "references": [], - "type": "table", - "data": { - "name": "sync_sessions", - "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": "epoch", - "getter_name": "epoch", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "enabled", - "getter_name": "enabled", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"enabled\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"enabled\" IN (0, 1))" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - } - ], - "fixed_sql": [ - { - "name": "currencies", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"currencies\" (\"code\" TEXT NOT NULL, \"exponent\" INTEGER NOT NULL, \"symbol\" TEXT NULL, \"name\" TEXT NOT NULL, PRIMARY KEY (\"code\"));" - } - ] - }, - { - "name": "groups", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"groups\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"default_currency\" TEXT NOT NULL REFERENCES currencies (code), \"is_direct\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_direct\" IN (0, 1)), \"simplify_debts\" INTEGER NOT NULL DEFAULT 1 CHECK (\"simplify_debts\" IN (0, 1)), \"created_by\" TEXT NULL, \"created_at\" INTEGER NOT NULL, \"archived_at\" INTEGER NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entries", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entries\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"kind\" TEXT NOT NULL, \"description\" TEXT NOT NULL DEFAULT '', \"category_id\" TEXT NULL, \"currency\" TEXT NOT NULL, \"amount_minor\" INTEGER NOT NULL, \"entry_date\" TEXT NOT NULL, \"occurred_at\" INTEGER NULL, \"time_zone\" TEXT NULL, \"split_kind\" TEXT NOT NULL, \"fx_rate\" REAL NULL, \"fx_source\" TEXT NULL, \"fx_at\" INTEGER NULL, \"notes\" TEXT NULL, \"created_by\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"seq\" INTEGER NULL, \"deleted_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entries_fts", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid);" - } - ] - }, - { - "name": "entries_fts_insert", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" - } - ] - }, - { - "name": "entries_fts_delete", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);END" - } - ] - }, - { - "name": "entries_fts_update", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" - } - ] - }, - { - "name": "profiles", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"profiles\" (\"id\" TEXT NOT NULL, \"display_name\" TEXT NULL, \"upi_vpa\" TEXT NULL, \"updated_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "members", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"members\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"profile_id\" TEXT NULL, \"display_name\" TEXT NOT NULL, \"joined_at\" INTEGER NOT NULL, \"left_at\" INTEGER NULL, \"upi_vpa\" TEXT NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "categories", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"categories\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"icon\" TEXT NOT NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entry_payers", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_payers\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" - } - ] - }, - { - "name": "entry_shares", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_shares\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, \"weight_micros\" INTEGER NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" - } - ] - }, - { - "name": "group_events", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"group_events\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"actor_id\" TEXT NULL REFERENCES members (id) ON DELETE CASCADE, \"created_at\" INTEGER NOT NULL, \"kind\" TEXT NOT NULL, \"subject_id\" TEXT NULL, \"entry\" TEXT NULL, \"member\" TEXT NULL, \"group\" TEXT NULL, \"link\" TEXT NULL, \"seq\" INTEGER NULL, \"ordinal\" INTEGER NULL, \"is_provisional\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_provisional\" IN (0, 1)), PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entry_conflicts", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_conflicts\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"attempted\" TEXT NOT NULL, \"base_seq\" INTEGER NULL, \"rejected_at\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\"));" - } - ] - }, - { - "name": "fx_rates", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"fx_rates\" (\"as_of\" TEXT NOT NULL, \"currency\" TEXT NOT NULL, \"rate\" REAL NOT NULL, \"source\" TEXT NOT NULL, PRIMARY KEY (\"as_of\", \"currency\"));" - } - ] - }, - { - "name": "outbox", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"outbox\" (\"target\" TEXT NOT NULL, \"target_id\" TEXT NOT NULL, \"revision\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"attempts\" INTEGER NOT NULL DEFAULT 0, \"next_attempt_at\" INTEGER NULL, \"last_error\" TEXT NULL, \"dead_lettered_at\" INTEGER NULL, PRIMARY KEY (\"target\", \"target_id\"));" - } - ] - }, - { - "name": "group_cursors", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"group_cursors\" (\"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"seq\" INTEGER NOT NULL DEFAULT 0, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"group_id\"));" - } - ] - }, - { - "name": "feed_cursors", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"feed_cursors\" (\"feed\" TEXT NOT NULL, \"cursor\" TEXT NULL, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"feed\"));" - } - ] - }, - { - "name": "sync_leases", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_leases\" (\"name\" TEXT NOT NULL, \"owner\" TEXT NOT NULL, \"expires_at\" INTEGER NOT NULL, PRIMARY KEY (\"name\"));" - } - ] - }, - { - "name": "sync_sessions", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_sessions\" (\"id\" TEXT NOT NULL, \"epoch\" TEXT NOT NULL, \"enabled\" INTEGER NOT NULL CHECK (\"enabled\" IN (0, 1)), PRIMARY KEY (\"id\"));" - } - ] - } - ] -} \ No newline at end of file diff --git a/drift_schemas/drift_schema_v9.json b/drift_schemas/drift_schema_v9.json deleted file mode 100644 index 4cd0fb2..0000000 --- a/drift_schemas/drift_schema_v9.json +++ /dev/null @@ -1,1940 +0,0 @@ -{ - "_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": "currencies", - "was_declared_in_moor": false, - "columns": [ - { - "name": "code", - "getter_name": "code", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "allowed-lengths": { - "min": 3, - "max": 3 - } - } - ] - }, - { - "name": "exponent", - "getter_name": "exponent", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "symbol", - "getter_name": "symbol", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "name", - "getter_name": "name", - "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": [ - "code" - ] - } - }, - { - "id": 1, - "references": [ - 0 - ], - "type": "table", - "data": { - "name": "groups", - "was_declared_in_moor": false, - "columns": [ - { - "name": "avatar_kind", - "getter_name": "avatarKind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'initials\\'')", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(AvatarKind.values,AvatarKind.unknownDefaultOpenApi)", - "dart_type_name": "AvatarKind" - } - }, - { - "name": "avatar_color", - "getter_name": "avatarColor", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(AvatarColor.values,AvatarColor.unknownDefaultOpenApi)", - "dart_type_name": "AvatarColor" - } - }, - { - "name": "avatar_emoji", - "getter_name": "avatarEmoji", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "avatar_icon", - "getter_name": "avatarIcon", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(AvatarIcon.values,AvatarIcon.unknownDefaultOpenApi)", - "dart_type_name": "AvatarIcon" - } - }, - { - "name": "avatar_photo", - "getter_name": "avatarPhoto", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "id", - "getter_name": "id", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "default_currency", - "getter_name": "defaultCurrency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES currencies (code)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES currencies (code)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "currencies", - "column": "code" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "is_direct", - "getter_name": "isDirect", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"is_direct\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"is_direct\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "simplify_debts", - "getter_name": "simplifyDebts", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"simplify_debts\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"simplify_debts\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('1')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "cover_kind", - "getter_name": "coverKind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'generated\\'')", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(CoverKind.values,CoverKind.unknownDefaultOpenApi)", - "dart_type_name": "CoverKind" - } - }, - { - "name": "cover_photo", - "getter_name": "coverPhoto", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "archived_at", - "getter_name": "archivedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 2, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "entries", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "kind", - "getter_name": "kind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(EntryKind.values,EntryKind.unknownDefaultOpenApi)", - "dart_type_name": "EntryKind" - } - }, - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'\\'')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "category_id", - "getter_name": "categoryId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "currency", - "getter_name": "currency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "entry_date", - "getter_name": "entryDate", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const CalendarDateConverter()", - "dart_type_name": "DateTime" - } - }, - { - "name": "occurred_at", - "getter_name": "occurredAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "time_zone", - "getter_name": "timeZone", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "split_kind", - "getter_name": "splitKind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(SplitKind.values,SplitKind.unknownDefaultOpenApi)", - "dart_type_name": "SplitKind" - } - }, - { - "name": "fx_rate", - "getter_name": "fxRate", - "moor_type": "double", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "fx_source", - "getter_name": "fxSource", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "fx_at", - "getter_name": "fxAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "notes", - "getter_name": "notes", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_by", - "getter_name": "createdBy", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "deleted_at", - "getter_name": "deletedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 3, - "references": [ - 2 - ], - "type": "table", - "data": { - "name": "entries_fts", - "was_declared_in_moor": true, - "columns": [ - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": "", - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "notes", - "getter_name": "notes", - "moor_type": "string", - "nullable": false, - "customConstraints": "", - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": true, - "create_virtual_stmt": "CREATE VIRTUAL TABLE \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid)", - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 4, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_insert", - "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" - } - }, - { - "id": 5, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_delete", - "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\nEND;" - } - }, - { - "id": 6, - "references": [ - 2, - 3 - ], - "type": "trigger", - "data": { - "on": 2, - "references_in_body": [ - 2, - 3 - ], - "name": "entries_fts_update", - "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" - } - }, - { - "id": 7, - "references": [], - "type": "table", - "data": { - "name": "profiles", - "was_declared_in_moor": false, - "columns": [ - { - "name": "avatar_kind", - "getter_name": "avatarKind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('\\'initials\\'')", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(AvatarKind.values,AvatarKind.unknownDefaultOpenApi)", - "dart_type_name": "AvatarKind" - } - }, - { - "name": "avatar_color", - "getter_name": "avatarColor", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(AvatarColor.values,AvatarColor.unknownDefaultOpenApi)", - "dart_type_name": "AvatarColor" - } - }, - { - "name": "avatar_emoji", - "getter_name": "avatarEmoji", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "avatar_icon", - "getter_name": "avatarIcon", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(AvatarIcon.values,AvatarIcon.unknownDefaultOpenApi)", - "dart_type_name": "AvatarIcon" - } - }, - { - "name": "avatar_photo", - "getter_name": "avatarPhoto", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "id", - "getter_name": "id", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "display_name", - "getter_name": "displayName", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "upi_vpa", - "getter_name": "upiVpa", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "updated_at", - "getter_name": "updatedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 8, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "members", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "profile_id", - "getter_name": "profileId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "display_name", - "getter_name": "displayName", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "joined_at", - "getter_name": "joinedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "left_at", - "getter_name": "leftAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "upi_vpa", - "getter_name": "upiVpa", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 9, - "references": [], - "type": "table", - "data": { - "name": "categories", - "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": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "icon", - "getter_name": "icon", - "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": [ - "id" - ] - } - }, - { - "id": 10, - "references": [ - 2, - 8 - ], - "type": "table", - "data": { - "name": "entry_payers", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "member_id", - "getter_name": "memberId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id", - "member_id" - ] - } - }, - { - "id": 11, - "references": [ - 2, - 8 - ], - "type": "table", - "data": { - "name": "entry_shares", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "member_id", - "getter_name": "memberId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id)", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id)" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": null - } - } - ] - }, - { - "name": "amount_minor", - "getter_name": "amountMinor", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "weight_micros", - "getter_name": "weightMicros", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id", - "member_id" - ] - } - }, - { - "id": 12, - "references": [ - 1, - 8 - ], - "type": "table", - "data": { - "name": "group_events", - "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": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "actor_id", - "getter_name": "actorId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "defaultConstraints": "REFERENCES members (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES members (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "members", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "kind", - "getter_name": "kind", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireEnumConverter(EventKind.values,EventKind.unknownDefaultOpenApi)", - "dart_type_name": "EventKind" - } - }, - { - "name": "subject_id", - "getter_name": "subjectId", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "entry", - "getter_name": "entry", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(EntrySnapshot.fromJson)", - "dart_type_name": "EntrySnapshot" - } - }, - { - "name": "member", - "getter_name": "member", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(MemberEventPayload.fromJson)", - "dart_type_name": "MemberEventPayload" - } - }, - { - "name": "group", - "getter_name": "group", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(GroupEventPayload.fromJson)", - "dart_type_name": "GroupEventPayload" - } - }, - { - "name": "link", - "getter_name": "link", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(LinkEventPayload.fromJson)", - "dart_type_name": "LinkEventPayload" - } - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "ordinal", - "getter_name": "ordinal", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "is_provisional", - "getter_name": "isProvisional", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"is_provisional\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"is_provisional\" IN (0, 1))" - }, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - }, - { - "id": 13, - "references": [ - 2, - 1 - ], - "type": "table", - "data": { - "name": "entry_conflicts", - "was_declared_in_moor": false, - "columns": [ - { - "name": "entry_id", - "getter_name": "entryId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "entries", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "attempted", - "getter_name": "attempted", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const WireJsonConverter(EntrySnapshot.fromJson)", - "dart_type_name": "EntrySnapshot" - } - }, - { - "name": "base_seq", - "getter_name": "baseSeq", - "moor_type": "int", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "rejected_at", - "getter_name": "rejectedAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "entry_id" - ] - } - }, - { - "id": 14, - "references": [], - "type": "table", - "data": { - "name": "fx_rates", - "was_declared_in_moor": false, - "columns": [ - { - "name": "as_of", - "getter_name": "asOf", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "currency", - "getter_name": "currency", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "rate", - "getter_name": "rate", - "moor_type": "double", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "source", - "getter_name": "source", - "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": [ - "as_of", - "currency" - ] - } - }, - { - "id": 15, - "references": [], - "type": "table", - "data": { - "name": "outbox", - "was_declared_in_moor": false, - "columns": [ - { - "name": "target", - "getter_name": "target", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(OutboxTarget.values)", - "dart_type_name": "OutboxTarget" - } - }, - { - "name": "target_id", - "getter_name": "targetId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "revision", - "getter_name": "revision", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "attempts", - "getter_name": "attempts", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "next_attempt_at", - "getter_name": "nextAttemptAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_error", - "getter_name": "lastError", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "dead_lettered_at", - "getter_name": "deadLetteredAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "target", - "target_id" - ] - } - }, - { - "id": 16, - "references": [ - 1 - ], - "type": "table", - "data": { - "name": "group_cursors", - "was_declared_in_moor": false, - "columns": [ - { - "name": "group_id", - "getter_name": "groupId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", - "dialectAwareDefaultConstraints": { - "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [ - { - "foreign_key": { - "to": { - "table": "groups", - "column": "id" - }, - "initially_deferred": false, - "on_update": null, - "on_delete": "cascade" - } - } - ] - }, - { - "name": "seq", - "getter_name": "seq", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const CustomExpression('0')", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_synced_at", - "getter_name": "lastSyncedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "group_id" - ] - } - }, - { - "id": 17, - "references": [], - "type": "table", - "data": { - "name": "feed_cursors", - "was_declared_in_moor": false, - "columns": [ - { - "name": "feed", - "getter_name": "feed", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "cursor", - "getter_name": "cursor", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "last_synced_at", - "getter_name": "lastSyncedAt", - "moor_type": "dateTime", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "feed" - ] - } - }, - { - "id": 18, - "references": [], - "type": "table", - "data": { - "name": "sync_leases", - "was_declared_in_moor": false, - "columns": [ - { - "name": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "owner", - "getter_name": "owner", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "expires_at", - "getter_name": "expiresAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "name" - ] - } - }, - { - "id": 19, - "references": [], - "type": "table", - "data": { - "name": "sync_sessions", - "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": "epoch", - "getter_name": "epoch", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "enabled", - "getter_name": "enabled", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"enabled\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"enabled\" IN (0, 1))" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [], - "explicit_pk": [ - "id" - ] - } - } - ], - "fixed_sql": [ - { - "name": "currencies", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"currencies\" (\"code\" TEXT NOT NULL, \"exponent\" INTEGER NOT NULL, \"symbol\" TEXT NULL, \"name\" TEXT NOT NULL, PRIMARY KEY (\"code\"));" - } - ] - }, - { - "name": "groups", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"groups\" (\"avatar_kind\" TEXT NOT NULL DEFAULT 'initials', \"avatar_color\" TEXT NULL, \"avatar_emoji\" TEXT NULL, \"avatar_icon\" TEXT NULL, \"avatar_photo\" TEXT NULL, \"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"default_currency\" TEXT NOT NULL REFERENCES currencies (code), \"is_direct\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_direct\" IN (0, 1)), \"simplify_debts\" INTEGER NOT NULL DEFAULT 1 CHECK (\"simplify_debts\" IN (0, 1)), \"cover_kind\" TEXT NOT NULL DEFAULT 'generated', \"cover_photo\" TEXT NULL, \"created_by\" TEXT NULL, \"created_at\" INTEGER NOT NULL, \"archived_at\" INTEGER NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entries", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entries\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"kind\" TEXT NOT NULL, \"description\" TEXT NOT NULL DEFAULT '', \"category_id\" TEXT NULL, \"currency\" TEXT NOT NULL, \"amount_minor\" INTEGER NOT NULL, \"entry_date\" TEXT NOT NULL, \"occurred_at\" INTEGER NULL, \"time_zone\" TEXT NULL, \"split_kind\" TEXT NOT NULL, \"fx_rate\" REAL NULL, \"fx_source\" TEXT NULL, \"fx_at\" INTEGER NULL, \"notes\" TEXT NULL, \"created_by\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"seq\" INTEGER NULL, \"deleted_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entries_fts", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid);" - } - ] - }, - { - "name": "entries_fts_insert", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" - } - ] - }, - { - "name": "entries_fts_delete", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);END" - } - ] - }, - { - "name": "entries_fts_update", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" - } - ] - }, - { - "name": "profiles", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"profiles\" (\"avatar_kind\" TEXT NOT NULL DEFAULT 'initials', \"avatar_color\" TEXT NULL, \"avatar_emoji\" TEXT NULL, \"avatar_icon\" TEXT NULL, \"avatar_photo\" TEXT NULL, \"id\" TEXT NOT NULL, \"display_name\" TEXT NULL, \"upi_vpa\" TEXT NULL, \"updated_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "members", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"members\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"profile_id\" TEXT NULL, \"display_name\" TEXT NOT NULL, \"joined_at\" INTEGER NOT NULL, \"left_at\" INTEGER NULL, \"upi_vpa\" TEXT NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "categories", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"categories\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"icon\" TEXT NOT NULL, PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entry_payers", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_payers\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" - } - ] - }, - { - "name": "entry_shares", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_shares\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, \"weight_micros\" INTEGER NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" - } - ] - }, - { - "name": "group_events", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"group_events\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"actor_id\" TEXT NULL REFERENCES members (id) ON DELETE CASCADE, \"created_at\" INTEGER NOT NULL, \"kind\" TEXT NOT NULL, \"subject_id\" TEXT NULL, \"entry\" TEXT NULL, \"member\" TEXT NULL, \"group\" TEXT NULL, \"link\" TEXT NULL, \"seq\" INTEGER NULL, \"ordinal\" INTEGER NULL, \"is_provisional\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_provisional\" IN (0, 1)), PRIMARY KEY (\"id\"));" - } - ] - }, - { - "name": "entry_conflicts", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"entry_conflicts\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"attempted\" TEXT NOT NULL, \"base_seq\" INTEGER NULL, \"rejected_at\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\"));" - } - ] - }, - { - "name": "fx_rates", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"fx_rates\" (\"as_of\" TEXT NOT NULL, \"currency\" TEXT NOT NULL, \"rate\" REAL NOT NULL, \"source\" TEXT NOT NULL, PRIMARY KEY (\"as_of\", \"currency\"));" - } - ] - }, - { - "name": "outbox", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"outbox\" (\"target\" TEXT NOT NULL, \"target_id\" TEXT NOT NULL, \"revision\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"attempts\" INTEGER NOT NULL DEFAULT 0, \"next_attempt_at\" INTEGER NULL, \"last_error\" TEXT NULL, \"dead_lettered_at\" INTEGER NULL, PRIMARY KEY (\"target\", \"target_id\"));" - } - ] - }, - { - "name": "group_cursors", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"group_cursors\" (\"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"seq\" INTEGER NOT NULL DEFAULT 0, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"group_id\"));" - } - ] - }, - { - "name": "feed_cursors", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"feed_cursors\" (\"feed\" TEXT NOT NULL, \"cursor\" TEXT NULL, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"feed\"));" - } - ] - }, - { - "name": "sync_leases", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_leases\" (\"name\" TEXT NOT NULL, \"owner\" TEXT NOT NULL, \"expires_at\" INTEGER NOT NULL, PRIMARY KEY (\"name\"));" - } - ] - }, - { - "name": "sync_sessions", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"sync_sessions\" (\"id\" TEXT NOT NULL, \"epoch\" TEXT NOT NULL, \"enabled\" INTEGER NOT NULL CHECK (\"enabled\" IN (0, 1)), PRIMARY KEY (\"id\"));" - } - ] - } - ] -} \ No newline at end of file diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 3320f61..ebae918 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -86,8 +86,8 @@ platform :android do if priority >= 4 UI.important( - "in-app update priority #{priority}: this release will interrupt " \ - "testers with a blocking update they cannot dismiss" + "in-app update priority #{priority}: installed clients can offer " \ + "an immediate update when Play makes it available and allows the flow" ) end UI.message("Uploading to the #{track} track with status #{status}") diff --git a/lib/application/backend_providers.dart b/lib/application/backend_providers.dart index ef71338..0780a23 100644 --- a/lib/application/backend_providers.dart +++ b/lib/application/backend_providers.dart @@ -13,6 +13,22 @@ import '../domain/auth_service.dart'; part 'backend_providers.g.dart'; +/// One compatibility gate for requests, local writes and the update notice. +@Riverpod(keepAlive: true) +ApiAccess apiAccess(Ref ref) { + final access = ApiAccess(); + ref.onDispose(access.dispose); + return access; +} + +@Riverpod(keepAlive: true) +bool requiresAppUpdate(Ref ref) { + final access = ref.watch(apiAccessProvider); + final subscription = access.changes.listen((_) => ref.invalidateSelf()); + ref.onDispose(subscription.cancel); + return access.updateRequired; +} + /// Where this device's session lives between launches. Overridden in `main`, /// which reads the token from secure storage before the first frame. @Riverpod(keepAlive: true) @@ -27,6 +43,7 @@ api.OpensplitApi? apiClient(Ref ref) { if (apiBaseUrl.isEmpty) return null; return buildApiClient( baseUrl: apiBaseUrl, + access: ref.watch(apiAccessProvider), token: ref.watch(sessionStoreProvider).read()?.token, ); } diff --git a/lib/application/ledger_providers.dart b/lib/application/ledger_providers.dart index 7628eec..9179bfc 100644 --- a/lib/application/ledger_providers.dart +++ b/lib/application/ledger_providers.dart @@ -15,6 +15,7 @@ import '../domain/member_identity.dart'; import '../domain/models/entry.dart'; import '../domain/models/group_event.dart'; import 'local_providers.dart'; +import 'backend_providers.dart'; part 'ledger_providers.g.dart'; @@ -106,6 +107,11 @@ Stream> members(Ref ref, String groupId) => Stream> entries(Ref ref, String groupId) => ref.watch(entryRepositoryProvider).watchEntries(groupId); +/// One historical entry, including records removed from current balances. +@riverpod +Stream entryRecord(Ref ref, String entryId) => + ref.watch(entryRepositoryProvider).watchEntry(entryId); + /// Everything a group screen needs, folded once. class GroupLedger { const GroupLedger({ @@ -118,6 +124,7 @@ class GroupLedger { required this.me, required this.profiles, required this.brokenEntries, + this.updatesAllowed = true, }); final Group group; @@ -141,9 +148,15 @@ class GroupLedger { /// pair's own debt to each other. final List transfers; - /// This device's member in the group, if it has one. + /// This account's member, including a locally queued departure. final Member? me; + /// Whether the running app is allowed to write to this server. + final bool updatesAllowed; + + /// Whether this account can change the shared ledger right now. + bool get canEdit => updatesAllowed && me != null && me!.isActive; + /// Accounts belonging to the members of this group, keyed by profile id. final Map profiles; @@ -237,6 +250,7 @@ GroupLedger? groupLedger(Ref ref, String groupId) { final broken = unbalancedEntries(entryList); return GroupLedger( + updatesAllowed: !ref.watch(requiresAppUpdateProvider), group: group, members: memberList, pastMembers: [ @@ -248,7 +262,7 @@ GroupLedger? groupLedger(Ref ref, String groupId) { transfers: group.simplifyDebts ? simplifyDebts(balances) : pairwiseDebts(entryList), - me: memberList.where((m) => m.profileId == accountId).firstOrNull, + me: everyone.where((m) => m.profileId == accountId).firstOrNull, profiles: profiles, brokenEntries: broken, ); diff --git a/lib/application/local_providers.dart b/lib/application/local_providers.dart index 7016a18..fadcf5b 100644 --- a/lib/application/local_providers.dart +++ b/lib/application/local_providers.dart @@ -14,6 +14,8 @@ import '../data/sync/outbox_queue.dart'; import '../domain/fx/fx_quote.dart'; import 'session_providers.dart'; +import 'backend_providers.dart'; + part 'local_providers.g.dart'; /// Who holds the session, synchronously. @@ -45,7 +47,10 @@ AppDatabase appDatabase(Ref ref) { /// Pending writes waiting for a server. @Riverpod(keepAlive: true) OutboxQueue outboxQueue(Ref ref) { - final queue = OutboxQueue(ref.watch(appDatabaseProvider)); + final queue = OutboxQueue( + ref.watch(appDatabaseProvider), + access: ref.watch(apiAccessProvider), + ); ref.onDispose(queue.dispose); return queue; } @@ -76,6 +81,7 @@ DriftGroupRepository groupRepository(Ref ref) => DriftGroupRepository( DriftEntryRepository entryRepository(Ref ref) => DriftEntryRepository( ref.watch(appDatabaseProvider), outbox: ref.watch(outboxQueueProvider), + accountId: ref.watch(currentAccountIdProvider), ); @Riverpod(keepAlive: true) diff --git a/lib/application/session_providers.dart b/lib/application/session_providers.dart index f2518bb..a1a4bf6 100644 --- a/lib/application/session_providers.dart +++ b/lib/application/session_providers.dart @@ -63,6 +63,7 @@ class SessionController extends _$SessionController { /// Ends the session. Future signOut() async { + ref.read(apiAccessProvider).requireWritable(); final auth = ref.read(authServiceProvider); if (auth == null) return; diff --git a/lib/application/sync_coordinator.dart b/lib/application/sync_coordinator.dart index c86f53d..dc9a493 100644 --- a/lib/application/sync_coordinator.dart +++ b/lib/application/sync_coordinator.dart @@ -71,6 +71,10 @@ class SyncCoordinator extends ChangeNotifier { SyncStatus get status => _status; + bool get _requiresUpdate => + _status.error is ApiFailure && + (_status.error as ApiFailure).isUpdateRequired; + /// Listens for triggers and syncs what happened while the app was closed. void start() { if (_disposed || _subscriptions.isNotEmpty) return; @@ -99,14 +103,14 @@ class SyncCoordinator extends ChangeNotifier { } Future syncAll() { - if (_disposed) return Future.value(); + if (_disposed || _requiresUpdate) return Future.value(); _allPending = true; return _begin(); } /// One group, unless nothing has synced cleanly yet: then everything. Future syncGroup(String groupId) { - if (_disposed) return Future.value(); + if (_disposed || _requiresUpdate) return Future.value(); if (!_status.hasCompletedFullSync || _status.error != null) { return syncAll(); } @@ -118,13 +122,13 @@ class SyncCoordinator extends ChangeNotifier { /// rest of the account waits for the next full sync: nothing else changed /// on this device, and another device's change arrives with its push. Future _syncWrites() { - if (_disposed) return Future.value(); + if (_disposed || _requiresUpdate) return Future.value(); _partialPending = true; return _begin(); } void _writeQueued() { - if (_disposed) return; + if (_disposed || _requiresUpdate) return; _writeTimer?.cancel(); _writeTimer = Timer(writeDelay, () => unawaited(_syncWrites())); } @@ -195,6 +199,10 @@ class SyncCoordinator extends ChangeNotifier { void _scheduleRetry() { Duration? delay; var retry = syncAll; + if (_requiresUpdate) { + _setStatus(isSyncing: false); + return; + } if (_status.error != null) { delay = Duration(seconds: math.min(5 * (1 << _failures), 300)); _failures = math.min(_failures + 1, 6); diff --git a/lib/application/sync_providers.dart b/lib/application/sync_providers.dart index ed29da5..8468d49 100644 --- a/lib/application/sync_providers.dart +++ b/lib/application/sync_providers.dart @@ -2,6 +2,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../data/network/network_signal.dart'; import '../data/sync/sync_engine.dart'; +import '../data/sync/api_client.dart'; import 'backend_providers.dart'; import 'local_providers.dart'; import 'sync_coordinator.dart'; @@ -14,6 +15,7 @@ SyncEngine? syncEngine(Ref ref) { if (client == null) return null; final engine = SyncEngine( db: ref.watch(appDatabaseProvider), + access: ref.watch(apiAccessProvider), client: client, outbox: ref.watch(outboxQueueProvider), accountId: ref.watch(currentAccountIdProvider), @@ -46,6 +48,16 @@ class SyncController extends _$SyncController { final accountId = ref.watch(currentAccountIdProvider); final engine = accountId == null ? null : ref.watch(syncEngineProvider); _coordinator = null; + if (ref.watch(requiresAppUpdateProvider)) { + return const SyncStatus( + lastReport: SyncReport( + pushed: 0, + pulled: 0, + failed: 0, + error: ApiFailure.updateRequired(), + ), + ); + } if (engine == null) return const SyncStatus(enabled: false); final coordinator = SyncCoordinator( @@ -71,11 +83,13 @@ class SyncController extends _$SyncController { void resumed() => _coordinator?.resumed(); Future retryFailed() async { + ref.read(apiAccessProvider).requireWritable(); await ref.read(outboxQueueProvider).retryDeadLetters(); await syncAll(); } Future discardFailed() async { + ref.read(apiAccessProvider).requireWritable(); await ref.read(syncEngineProvider)?.discardRefused(); await syncAll(); } diff --git a/lib/data/auth/better_auth_service.dart b/lib/data/auth/better_auth_service.dart index a903715..bb1083d 100644 --- a/lib/data/auth/better_auth_service.dart +++ b/lib/data/auth/better_auth_service.dart @@ -10,7 +10,7 @@ import 'google_sign_in_gateway.dart'; import 'pending_identity_redirect.dart'; import 'session_store.dart'; -/// The session, through the server's `/api/identity` routes, which wrap Better +/// The session, through the server's `/identity` routes, which wrap Better /// Auth and answer in contract types. final class BetterAuthService implements AuthService { BetterAuthService({ diff --git a/lib/data/local/database.dart b/lib/data/local/database.dart index 0c474c2..1cc79e8 100644 --- a/lib/data/local/database.dart +++ b/lib/data/local/database.dart @@ -79,7 +79,7 @@ class AppDatabase extends _$AppDatabase { /// Bump this on **any** change to a table in `tables.dart`. @override - int get schemaVersion => 10; + int get schemaVersion => 1; /// Timestamps are stored as ISO-8601 text rather than Unix seconds. @override @@ -108,10 +108,6 @@ class AppDatabase extends _$AppDatabase { MigrationStrategy get migration => MigrationStrategy( onCreate: (m) => m.createAll(), - // Every schema change rebuilds the local database from empty, and the - // device re-syncs. - onUpgrade: (m, from, to) => destructiveFallback.onUpgrade(m, from, to), - beforeOpen: (details) async { if (_resumeSession) { await (update(syncSessions)..where((t) => t.id.equals('account'))) @@ -134,8 +130,6 @@ class AppDatabase extends _$AppDatabase { } extension GroupState on Group { - bool get isArchived => archivedAt != null; - Avatar get avatar => Avatar.fromColumns(( kind: avatarKind, color: avatarColor, diff --git a/lib/data/local/open_database.dart b/lib/data/local/open_database.dart index 858cdd8..3e58dbf 100644 --- a/lib/data/local/open_database.dart +++ b/lib/data/local/open_database.dart @@ -4,6 +4,12 @@ import 'open_database_native.dart' if (dart.library.js_interop) 'open_database_web.dart' as platform; +/// The stable storage name for an account's local ledger. +/// +/// The `v1` label stays fixed across schema upgrades. +/// The pre-cutover `opensplit-` files are unused. +String accountDatabaseName(String accountId) => 'opensplit-v1-$accountId'; + /// Opens the database file belonging to [accountId]. QueryExecutor openAccountDatabase(String accountId) => - platform.openPlatformDatabase('opensplit-$accountId'); + platform.openPlatformDatabase(accountDatabaseName(accountId)); diff --git a/lib/data/local/remove_group.dart b/lib/data/local/remove_group.dart new file mode 100644 index 0000000..4bc39a1 --- /dev/null +++ b/lib/data/local/remove_group.dart @@ -0,0 +1,28 @@ +import 'package:drift/drift.dart'; + +import 'database.dart'; +import 'tables.dart'; + +/// Removes a group's cache and queued edits after access definitively ends. +/// +/// The caller owns the transaction and checks the active sync session first. +Future removeGroupInTransaction(AppDatabase db, String groupId) async { + final memberIds = db.selectOnly(db.members) + ..addColumns([db.members.id]) + ..where(db.members.groupId.equals(groupId)); + final entryIds = db.selectOnly(db.entries) + ..addColumns([db.entries.id]) + ..where(db.entries.groupId.equals(groupId)); + await (db.delete(db.outbox)..where( + (row) => + (row.target.equalsValue(OutboxTarget.group) & + row.targetId.equals(groupId)) | + (row.target.equalsValue(OutboxTarget.member) & + row.targetId.isInQuery(memberIds)) | + (row.target.equalsValue(OutboxTarget.entry) & + row.targetId.isInQuery(entryIds)), + )) + .go(); + // Foreign-key cascades remove entries, splits, conflicts, events and cursor. + await (db.delete(db.groups)..where((row) => row.id.equals(groupId))).go(); +} diff --git a/lib/data/local/tables.dart b/lib/data/local/tables.dart index a9de69f..4b7a543 100644 --- a/lib/data/local/tables.dart +++ b/lib/data/local/tables.dart @@ -205,7 +205,6 @@ class Groups extends Table with AvatarColumns { /// its members arrive in one page in no particular order. TextColumn get createdBy => text().nullable()(); DateTimeColumn get createdAt => dateTime()(); - DateTimeColumn get archivedAt => dateTime().nullable()(); IntColumn get seq => integer().nullable()(); @override @@ -396,6 +395,7 @@ class Outbox extends Table { IntColumn get attempts => integer().withDefault(const Constant(0))(); DateTimeColumn get nextAttemptAt => dateTime().nullable()(); TextColumn get lastError => text().nullable()(); + TextColumn get failureCode => text().nullable()(); /// Set when the server refused this permanently. Kept, not deleted, so the /// person can see which write never reached the group. diff --git a/lib/data/platform/app_update_service.dart b/lib/data/platform/app_update_service.dart index 8fa7291..bb84a6e 100644 --- a/lib/data/platform/app_update_service.dart +++ b/lib/data/platform/app_update_service.dart @@ -2,6 +2,7 @@ import 'dart:developer' as developer; import 'package:flutter/foundation.dart'; import 'package:in_app_update/in_app_update.dart'; +import 'package:in_app_review/in_app_review.dart'; /// How badly a waiting update wants to be installed. enum UpdateUrgency { @@ -12,7 +13,7 @@ enum UpdateUrgency { /// costs nothing. The ordinary case. flexible, - /// A full-screen, blocking flow that the person cannot dismiss. + /// A full-screen installation flow. Play can report cancellation or failure. immediate, } @@ -23,8 +24,8 @@ class AppUpdateService { bool get isSupported => !kIsWeb && defaultTargetPlatform == TargetPlatform.android; - /// The priority at which an update stops being optional. - static const int _blockingFrom = 4; + /// The priority at which an ordinary update offer uses the immediate flow. + static const int _immediateFrom = 4; /// What Play is holding for this app, and how much it matters. Future check() async { @@ -36,7 +37,7 @@ class AppUpdateService { } // Both halves are required. - if ((info.updatePriority) >= _blockingFrom && + if ((info.updatePriority) >= _immediateFrom && info.immediateUpdateAllowed) { return UpdateUrgency.immediate; } @@ -57,6 +58,35 @@ class AppUpdateService { } } + /// Updates a build whose API has been retired, regardless of Play priority. + /// + /// Canceling keeps saved data readable. If Play cannot run the immediate + /// flow, its listing lets the user install once the release is available. + Future updateRequired() async { + if (isSupported) { + try { + final info = await InAppUpdate.checkForUpdate(); + if (info.updateAvailability == + UpdateAvailability.developerTriggeredUpdateInProgress || + (info.updateAvailability == UpdateAvailability.updateAvailable && + info.immediateUpdateAllowed)) { + final result = await installNow(); + if (result != AppUpdateResult.inAppUpdateFailed) return; + } + } catch (error) { + developer.log( + 'Could not start the Play update', + name: 'opensplit.update', + error: error, + ); + } + } + await openStoreListing(); + } + + /// Opens the platform store if an in-app update cannot run. + Future openStoreListing() => InAppReview.instance.openStoreListing(); + /// Downloads in the background. Resolves when the download finishes, is /// declined, or fails. Future download() => InAppUpdate.startFlexibleUpdate(); diff --git a/lib/data/push/background_handler.dart b/lib/data/push/background_handler.dart index 39bfc3d..7e235ac 100644 --- a/lib/data/push/background_handler.dart +++ b/lib/data/push/background_handler.dart @@ -1,4 +1,5 @@ import 'package:firebase_core/firebase_core.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; @@ -34,6 +35,7 @@ Future handleBackgroundEntryMessage(RemoteMessage message) async { // exists — and it does not, because nothing here went through main(). WidgetsFlutterBinding.ensureInitialized(); + ApiAccess? access; AppDatabase? db; OutboxQueue? outbox; try { @@ -57,6 +59,10 @@ Future handleBackgroundEntryMessage(RemoteMessage message) async { final session = await readBackgroundSession(); if (session == null) return; final profileId = session.account.id; + access = ApiAccess.persisted( + await SharedPreferences.getInstance(), + baseUrl: apiBaseUrl, + ); // A second connection to the same file — the same file, because the ledger // is named after the account and this isolate resolved the same account @@ -65,14 +71,19 @@ Future handleBackgroundEntryMessage(RemoteMessage message) async { final syncing = await readSyncSession(db); if (!syncing.enabled) return; - outbox = OutboxQueue(db); + outbox = OutboxQueue(db, access: access); final engine = SyncEngine( db: db, // Its own client with the stored token: no Riverpod container here, and // no cookie jar on Android. - client: buildApiClient(baseUrl: apiBaseUrl, token: session.token), + client: buildApiClient( + baseUrl: apiBaseUrl, + token: session.token, + access: access, + ), outbox: outbox, accountId: profileId, + access: access, ); // Sync first. The notification describes what is now on the device, not @@ -128,6 +139,7 @@ Future handleBackgroundEntryMessage(RemoteMessage message) async { } catch (_) { // Nothing to report to and nobody to report it to. } finally { + await access?.dispose(); await outbox?.dispose(); await db?.close(); } diff --git a/lib/data/repositories/drift_activity_repository.dart b/lib/data/repositories/drift_activity_repository.dart index f2d5e90..51d81d2 100644 --- a/lib/data/repositories/drift_activity_repository.dart +++ b/lib/data/repositories/drift_activity_repository.dart @@ -99,9 +99,7 @@ final class DriftActivityRepository { displayName: row.member?.displayName ?? 'Someone', previousName: row.member?.previousName, ), - EventKind.groupRenamed || - EventKind.groupArchived || - EventKind.groupRestored => GroupChanged( + EventKind.groupRenamed => GroupChanged( id: row.id, groupId: row.groupId, actorId: row.actorId, diff --git a/lib/data/repositories/drift_entry_repository.dart b/lib/data/repositories/drift_entry_repository.dart index 28d9a53..3b92522 100644 --- a/lib/data/repositories/drift_entry_repository.dart +++ b/lib/data/repositories/drift_entry_repository.dart @@ -11,18 +11,21 @@ import '../local/database.dart'; import '../local/entry_writer.dart'; import '../local/tables.dart'; import '../sync/outbox_queue.dart'; +import 'group_write_access.dart'; /// Local-first entry storage. final class DriftEntryRepository { DriftEntryRepository( this._db, { this.outbox, + this.accountId, Uuid? uuid, DateTime Function()? clock, }) : _uuid = uuid ?? const Uuid(), _clock = clock ?? DateTime.now; final AppDatabase _db; + final String? accountId; /// Null in a purely local build, where there is nothing to sync to. final OutboxQueue? outbox; @@ -39,6 +42,7 @@ final class DriftEntryRepository { required String? actorId, required DateTime at, }) async { + await requireGroupWrite(_db, after.row.groupId, accountId); final snapshot = snapshotOf(after); final latest = await _latestSnapshot(after.id); @@ -206,19 +210,11 @@ final class DriftEntryRepository { 'This expense is already recorded. Reopen it to make changes.', ); } - await _unarchive(entry.row.groupId); await _writeWithSnapshot(after: entry, actorId: createdBy, at: at); }); return entry; } - /// A group somebody is still using is not dormant. - Future _unarchive(String groupId) async { - await (_db.update(_db.groups) - ..where((t) => t.id.equals(groupId) & t.archivedAt.isNotNull())) - .write(const GroupsCompanion(archivedAt: Value(null))); - } - /// Replaces an existing entry's contents, keeping its id and creation /// metadata. Future update( diff --git a/lib/data/repositories/drift_group_repository.dart b/lib/data/repositories/drift_group_repository.dart index 211cf51..eeeaa4a 100644 --- a/lib/data/repositories/drift_group_repository.dart +++ b/lib/data/repositories/drift_group_repository.dart @@ -3,9 +3,12 @@ import 'package:opensplit_api/opensplit_api.dart' show AvatarKind, CoverKind; import 'package:uuid/uuid.dart'; import '../../domain/settle/upi.dart'; +import '../../domain/balance/balance_fold.dart'; +import 'drift_entry_repository.dart'; import '../local/database.dart'; import '../local/tables.dart'; import '../sync/outbox_queue.dart'; +import 'group_write_access.dart'; /// A group as a list of groups shows it. class GroupListing { @@ -25,8 +28,8 @@ class GroupListing { /// The newest line in the group's activity feed, or when it was made. final DateTime lastActivityAt; - /// In the main list: neither put away nor left. - bool get isCurrent => !group.isArchived && !hasLeft; + /// In the main list while this account participates. + bool get isCurrent => !hasLeft; } /// Local-first group and membership storage. @@ -178,11 +181,17 @@ final class DriftGroupRepository { } await _db.transaction(() async { + await requireGroupWrite(_db, group.id, accountId); + final current = await getGroup(group.id); + if (current?.seq != group.seq) { + throw StateError( + 'The group changed. Reopen its settings and try again.', + ); + } await (_db.update(_db.groups)..where((t) => t.id.equals(group.id))).write( GroupsCompanion( name: Value(trimmed), simplifyDebts: Value(group.simplifyDebts), - archivedAt: Value(group.archivedAt), avatarKind: Value(group.avatarKind), avatarColor: Value(group.avatarColor), avatarEmoji: Value(group.avatarEmoji), @@ -198,10 +207,15 @@ final class DriftGroupRepository { } /// Leaves a group by marking your own member row as having left. The group - /// stays on this device, read-only, among the groups put away. The server - /// refuses this while you owe or are owed anything in it. + /// disappears from this account; the server removes it when nobody remains. + /// Leaving requires settlement; the last account must settle the whole group. Future leaveGroup({required String memberId}) async { await _db.transaction(() async { + final member = await _requireMemberWrite(memberId); + if (accountId != null && member.profileId != accountId) { + throw StateError('You can only leave your own membership.'); + } + await _requireSettledDeparture(member); await (_db.update(_db.members)..where((t) => t.id.equals(memberId))) .write(MembersCompanion(leftAt: Value(_clock()))); await outbox?.enqueueInTransaction(OutboxTarget.member, memberId); @@ -236,6 +250,7 @@ final class DriftGroupRepository { ); await _db.transaction(() async { + await requireGroupWrite(_db, groupId, accountId); await _db.into(_db.members).insert(member); await outbox?.enqueueInTransaction(OutboxTarget.member, member.id); }); @@ -252,6 +267,7 @@ final class DriftGroupRepository { ); } await _db.transaction(() async { + await _requireMemberWrite(memberId); await (_db.update(_db.members)..where((t) => t.id.equals(memberId))) .write(MembersCompanion(displayName: Value(trimmed))); await outbox?.enqueueInTransaction(OutboxTarget.member, memberId); @@ -264,6 +280,8 @@ final class DriftGroupRepository { // Marked as left, never deleted. Their name still has to render on every // expense they were part of, and their balance still has to be settleable. await _db.transaction(() async { + final member = await _requireMemberWrite(memberId); + await _requireSettledDeparture(member); await (_db.update(_db.members)..where((t) => t.id.equals(memberId))) .write(MembersCompanion(leftAt: Value(_clock()))); await outbox?.enqueueInTransaction(OutboxTarget.member, memberId); @@ -277,6 +295,7 @@ final class DriftGroupRepository { throw ArgumentError.value(vpa, 'vpa', 'Not a UPI ID.'); } await _db.transaction(() async { + await _requireMemberWrite(memberId); await (_db.update( _db.members, )..where((t) => t.id.equals(memberId))).write( @@ -302,7 +321,7 @@ final class DriftGroupRepository { for (final members in byGroup.values) { if (!members.any((m) => m.profileId == profileId)) continue; final others = members.any( - (m) => m.profileId != null && m.profileId != profileId, + (m) => m.isActive && m.profileId != null && m.profileId != profileId, ); if (others) { shared++; @@ -313,12 +332,32 @@ final class DriftGroupRepository { return (solo: solo, shared: shared); } - Future setArchived(String groupId, {required bool archived}) async { - await _db.transaction(() async { - await (_db.update(_db.groups)..where((t) => t.id.equals(groupId))).write( - GroupsCompanion(archivedAt: Value(archived ? _clock() : null)), + Future _requireSettledDeparture(Member member) async { + final entries = await DriftEntryRepository(_db).getEntries(member.groupId); + final balances = foldBalances(entries); + if (balances.any((balance) => balance.memberId == member.id)) { + throw StateError( + 'Settle this member’s balance before leaving or removing them.', ); - await outbox?.enqueueInTransaction(OutboxTarget.group, groupId); - }); + } + final members = await getMembers(member.groupId); + final lastAccount = + member.profileId != null && + !members.any( + (other) => other.id != member.id && other.profileId != null, + ); + if (lastAccount && balances.isNotEmpty) { + throw StateError( + 'Settle all balances before the last account holder leaves.', + ); + } + } + + Future _requireMemberWrite(String memberId) async { + final member = await (_db.select( + _db.members, + )..where((row) => row.id.equals(memberId))).getSingle(); + await requireGroupWrite(_db, member.groupId, accountId); + return member; } } diff --git a/lib/data/repositories/group_write_access.dart b/lib/data/repositories/group_write_access.dart new file mode 100644 index 0000000..a98fa61 --- /dev/null +++ b/lib/data/repositories/group_write_access.dart @@ -0,0 +1,30 @@ +import 'package:drift/drift.dart'; + +import '../local/database.dart'; + +/// Checks membership at the write boundary, inside the caller's transaction. +/// +/// A null account is a purely local ledger. Synced repositories always supply +/// their account, so a stale screen or direct route cannot bypass this rule. +Future requireGroupWrite( + AppDatabase db, + String groupId, + String? accountId, +) async { + final group = await (db.select( + db.groups, + )..where((row) => row.id.equals(groupId))).getSingleOrNull(); + if (group == null) throw StateError('This group is not on this device.'); + if (accountId == null) return; + final member = + await (db.select(db.members)..where( + (row) => + row.groupId.equals(groupId) & + row.profileId.equals(accountId) & + row.leftAt.isNull(), + )) + .getSingleOrNull(); + if (member == null) { + throw StateError('You are no longer a member of this group.'); + } +} diff --git a/lib/data/sync/api_client.dart b/lib/data/sync/api_client.dart index 2b8f71c..2554d87 100644 --- a/lib/data/sync/api_client.dart +++ b/lib/data/sync/api_client.dart @@ -1,20 +1,28 @@ +import 'dart:async'; +import 'dart:developer' as developer; + import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:opensplit_api/opensplit_api.dart' as api; +import 'package:shared_preferences/shared_preferences.dart'; /// The security scheme name the contract gives the bearer token. const bearerScheme = 'bearer'; /// The client every backend request goes through. +/// +/// [baseUrl] is the backend origin. The generated contract owns its API path. api.OpensplitApi buildApiClient({ required String baseUrl, String? token, + ApiAccess? access, Duration timeout = const Duration(seconds: 20), }) { + final endpoint = Uri.parse(baseUrl).resolve(api.OpensplitApi.basePath); final client = api.OpensplitApi( dio: Dio( BaseOptions( - baseUrl: baseUrl, + baseUrl: endpoint.toString(), connectTimeout: timeout, receiveTimeout: timeout, sendTimeout: timeout, @@ -22,10 +30,110 @@ api.OpensplitApi buildApiClient({ ), ), ); + if (access != null) { + client.dio.interceptors.add( + InterceptorsWrapper( + onRequest: (request, handler) { + if (access.updateRequired) { + handler.reject( + DioException( + requestOptions: request, + type: DioExceptionType.cancel, + error: const ApiFailure.updateRequired(), + ), + ); + } else { + handler.next(request); + } + }, + onResponse: (response, handler) { + if (access.updateRequired) { + handler.reject( + DioException( + requestOptions: response.requestOptions, + type: DioExceptionType.cancel, + error: const ApiFailure.updateRequired(), + ), + ); + } else { + handler.next(response); + } + }, + onError: (error, handler) { + if (ApiFailure.from(error).isUpdateRequired) access.requireUpdate(); + handler.next(error); + }, + ), + ); + } if (token != null) client.setBearerAuth(bearerScheme, token); return client; } +/// Whether this running build can still write to the server. +/// +/// Retirement is final: only installing a compatible app restores editing. +/// The restriction belongs to the API contract, shared across accounts. +class ApiAccess { + ApiAccess() : _updateRequired = false, _remember = null; + + ApiAccess._(this._updateRequired, this._remember); + + /// Remembers retirement across offline launches of the same API contract. + /// + /// A compatible build has a new API base and therefore a different key. + factory ApiAccess.persisted( + SharedPreferences preferences, { + required String baseUrl, + }) { + final endpoint = Uri.parse(baseUrl).resolve(api.OpensplitApi.basePath); + final key = 'opensplit.retiredApi.$endpoint'; + return ApiAccess._( + preferences.getBool(key) ?? false, + () => preferences.setBool(key, true), + ); + } + + final _changes = StreamController.broadcast(sync: true); + + /// Announces retirement while the current restriction stays synchronously readable. + Stream get changes => _changes.stream; + + Future dispose() => _changes.close(); + + final Future Function()? _remember; + bool _updateRequired; + + /// True after the server has retired this running build’s API. + bool get updateRequired => _updateRequired; + + /// Latches retirement; only a build using a supported contract can edit. + void requireUpdate() { + if (_updateRequired) return; + _updateRequired = true; + unawaited(_saveRetirement()); + _changes.add(null); + } + + Future _saveRetirement() async { + try { + await _remember?.call(); + } catch (error, stackTrace) { + developer.log( + 'Could not remember API retirement', + name: 'opensplit.update', + error: error, + stackTrace: stackTrace, + ); + } + } + + /// Stops a local transaction before it can commit a new queued edit. + void requireWritable() { + if (_updateRequired) throw const ApiFailure.updateRequired(); + } +} + /// A request the server refused, or that never got an answer. class ApiFailure implements Exception { const ApiFailure( @@ -35,8 +143,19 @@ class ApiFailure implements Exception { this.unreachable = false, }); + const ApiFailure.updateRequired() + : this( + 'Update OpenSplit to make changes. Saved data is still available.', + retry: api.Retry.permanent, + code: api.ErrorCode.updateRequired, + ); + + /// Retirement stops sync without rejecting the pending financial change. + bool get isUpdateRequired => code == api.ErrorCode.updateRequired; + /// Reads the `{error: {code, message, retry}}` envelope off a failed request. factory ApiFailure.from(DioException error) { + if (error.error case final ApiFailure failure) return failure; final status = error.response?.statusCode; final body = error.response?.data; final envelope = body is Map ? _decode(body) : null; diff --git a/lib/data/sync/apply_changes.dart b/lib/data/sync/apply_changes.dart index 2aee4d1..e2b8b74 100644 --- a/lib/data/sync/apply_changes.dart +++ b/lib/data/sync/apply_changes.dart @@ -8,6 +8,7 @@ import 'package:opensplit_api/opensplit_api.dart' as api; import '../local/database.dart'; import '../local/entry_writer.dart'; +import '../local/remove_group.dart'; import '../local/tables.dart'; import 'wire.dart'; @@ -67,31 +68,28 @@ Future applyGroupChanges( AppDatabase db, api.ChangePage page, { required DateTime now, + Future Function()? assertActive, }) async { final groupId = page.groupId; if (page.purgedAt != null) { // The group was collected on the server; this page is the one that says so. await db.transaction(() async { - await (db.delete(db.groups)..where((t) => t.id.equals(groupId))).go(); - await (db.delete( - db.groupCursors, - )..where((t) => t.groupId.equals(groupId))).go(); + await assertActive?.call(); + await removeGroupInTransaction(db, groupId); }); return 0; } return db.transaction(() async { + await assertActive?.call(); // Foreign-key order: the group, its members, entries naming them, then // events naming both. final group = page.group; - if (group != null && - !(await _dirtyIds(db, OutboxTarget.group)).contains(group.id)) { - // Companions with their nulls stated: a data class upserts a null as - // "leave it", and restoring, rejoining and clearing are all nulls. - await db - .into(db.groups) - .insertOnConflictUpdate(group.toRow().toCompanion(false)); + if (group != null) { + if (!(await _dirtyIds(db, OutboxTarget.group)).contains(group.id)) { + await db.into(db.groups).insertOnConflictUpdate(group.toRow()); + } } final dirtyMembers = await _dirtyIds(db, OutboxTarget.member); diff --git a/lib/data/sync/outbox_queue.dart b/lib/data/sync/outbox_queue.dart index 31a947c..1ce380c 100644 --- a/lib/data/sync/outbox_queue.dart +++ b/lib/data/sync/outbox_queue.dart @@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart'; import '../local/database.dart'; import '../local/tables.dart'; import 'sync_session.dart'; +import 'api_client.dart'; /// A write the server refused outright, named the way its author would name it. class FailedWrite { @@ -15,6 +16,7 @@ class FailedWrite { required this.label, required this.reason, required this.failedAt, + this.code, }); final OutboxTarget target; @@ -27,13 +29,29 @@ class FailedWrite { final String reason; final DateTime failedAt; + + /// Stable refusal category for choosing a useful recovery action. + final String? code; + + /// Repeating a denied or stale write cannot fix lost access or a stale base. + bool get canRetry => !const { + 'not_member', + 'stale_base', + 'not_settled', + 'group_purged', + }.contains(code); } /// Pending local writes waiting to reach the server. +/// +/// Items refer to ledger rows; the current client builds their API requests. +/// Breaking model changes must migrate those rows and retain pending edits +/// before synchronization resumes. class OutboxQueue { - OutboxQueue(this._db, {DateTime Function()? clock}) + OutboxQueue(this._db, {DateTime Function()? clock, this._access}) : _clock = clock ?? DateTime.now; + final ApiAccess? _access; final AppDatabase _db; final DateTime Function() _clock; @@ -68,6 +86,7 @@ class OutboxQueue { OutboxTarget target, String targetId, ) async { + _access?.requireWritable(); if (!(await readSyncSession(_db)).enabled) { throw StateError('This account session has ended.'); } @@ -94,11 +113,13 @@ class OutboxQueue { attempts: const Value(0), nextAttemptAt: const Value(null), lastError: const Value(null), + failureCode: const Value(null), deadLetteredAt: const Value(null), ), ), ); + _access?.requireWritable(); if (_queued.hasListener) _queued.add(null); } @@ -209,6 +230,7 @@ class OutboxQueue { label: await _labelFor(row.target, row.targetId), reason: row.lastError ?? 'The server refused it without saying why.', failedAt: row.deadLetteredAt!, + code: row.failureCode, ); } @@ -239,16 +261,27 @@ class OutboxQueue { /// Puts refused writes back in the queue. Future retryDeadLetters() async { - return (_db.update( - _db.outbox, - )..where((t) => t.deadLetteredAt.isNotNull())).write( - const OutboxCompanion( - deadLetteredAt: Value(null), - nextAttemptAt: Value(null), - lastError: Value(null), - attempts: Value(0), - ), - ); + _access?.requireWritable(); + return (_db.update(_db.outbox)..where( + (t) => + t.deadLetteredAt.isNotNull() & + (t.failureCode.isNull() | + t.failureCode.isNotIn(const [ + 'not_member', + 'stale_base', + 'not_settled', + 'group_purged', + ])), + )) + .write( + const OutboxCompanion( + deadLetteredAt: Value(null), + nextAttemptAt: Value(null), + lastError: Value(null), + failureCode: Value(null), + attempts: Value(0), + ), + ); } /// The item exactly as [item] read it. A response for an older edit must @@ -270,13 +303,19 @@ class OutboxQueue { (_db.delete(_db.outbox)..where((t) => _sameEdit(t, item))).go(); /// Records a failed attempt and schedules the next one. - Future fail(OutboxRow item, String error, {bool permanent = false}) { + Future fail( + OutboxRow item, + String error, { + bool permanent = false, + String? code, + }) { final update = _db.update(_db.outbox)..where((t) => _sameEdit(t, item)); if (permanent) { return update.write( OutboxCompanion( deadLetteredAt: Value(_clock()), lastError: Value(error), + failureCode: Value(code), ), ); } @@ -290,6 +329,7 @@ class OutboxQueue { attempts: Value(attempts), nextAttemptAt: Value(_clock().add(backoff)), lastError: Value(error), + failureCode: Value(code), ), ); } diff --git a/lib/data/sync/sync_engine.dart b/lib/data/sync/sync_engine.dart index ffea910..1f8fc37 100644 --- a/lib/data/sync/sync_engine.dart +++ b/lib/data/sync/sync_engine.dart @@ -9,6 +9,7 @@ import '../../domain/models/entry.dart'; import '../../domain/models/entry_snapshot.dart'; import '../local/database.dart'; import '../local/entry_writer.dart'; +import '../local/remove_group.dart'; import '../local/tables.dart'; import 'api_client.dart'; import 'apply_changes.dart'; @@ -64,6 +65,7 @@ class SyncEngine { required this.client, required this.outbox, this.accountId, + this.access, SyncGate? gate, DateTime Function()? clock, this.pageSize = 200, @@ -80,6 +82,7 @@ class SyncEngine { ); } + final ApiAccess? access; final AppDatabase db; final api.OpensplitApi client; final OutboxQueue outbox; @@ -105,6 +108,7 @@ class SyncEngine { } Future _assertActive() async { + access?.requireWritable(); final session = await readSyncSession(db); if (_disposed || !session.enabled || @@ -134,47 +138,14 @@ class SyncEngine { }.difference(skipped).toList()..sort(); } - /// Groups a pull can only waste a request on: ones the server has never - /// acknowledged (their creation is still in the outbox, and the server - /// would answer `no_group`), and ones this account has left for good. + /// Groups whose creation has not reached the server yet. Future> _unreadable() async => { for (final group in await (db.select( db.groups, )..where((t) => t.seq.isNull())).get()) group.id, - ...await _leftForGood(), }; - /// Groups this account has left or been removed from, and this device has - /// already read that back from the server. A pull of one can only ever come - /// back empty. An invite link brings the group back, and the server then - /// names it among the account's groups again. - Future> _leftForGood() async { - final account = accountId; - if (account == null) return const {}; - - final departed = - await (db.select(db.members)..where( - (t) => - t.profileId.equals(account) & - t.leftAt.isNotNull() & - t.seq.isNotNull(), - )) - .get(); - if (departed.isEmpty) return const {}; - - final cursors = await _groupCursors(); - // Unsent, the leave is this device's word alone, and the server may still - // refuse it. - final unsent = await outbox.unsentIds(OutboxTarget.member); - return { - for (final member in departed) - if (!unsent.contains(member.id) && - (cursors[member.groupId] ?? 0) >= member.seq!) - member.groupId, - }; - } - /// Pushes what is waiting, then reads [groupIds] and every group the push /// wrote to. What a screen, a local write or a push message asks for: the /// shared feeds are left to [syncEverything]. @@ -274,10 +245,35 @@ class SyncEngine { await outbox.complete(item); sent++; } on ApiFailure catch (e) { + // Retiring a contract does not reject or acknowledge any queued edit. + if (e.isUpdateRequired) rethrow; + if (e.code == api.ErrorCode.notMember || + e.code == api.ErrorCode.groupPurged) { + if (await _groupFor(item) case final groupId?) { + await _removeGroup(groupId); + sent++; + continue; + } + } + if (e.retry != api.Retry.transient) { + await _restoreRefusedDeparture(item); + } switch (e.retry) { case api.Retry.stale: - if (await _parkConflict(item) case final groupId?) { - groups.add(groupId); + if (item.target == OutboxTarget.entry) { + if (await _parkConflict(item) case final groupId?) { + groups.add(groupId); + } + } else { + await outbox.fail( + item, + e.message, + permanent: true, + code: e.code?.value, + ); + if (await _groupFor(item) case final groupId?) { + groups.add(groupId); + } } case api.Retry.transient: await outbox.fail(item, e.message); @@ -285,7 +281,15 @@ class SyncEngine { // Refused identically next time; retrying would wedge the queue. case api.Retry.permanent: case api.Retry.unknownDefaultOpenApi: - await outbox.fail(item, e.message, permanent: true); + await outbox.fail( + item, + e.message, + permanent: true, + code: e.code?.value, + ); + if (await _groupFor(item) case final groupId?) { + groups.add(groupId); + } } failed++; } catch (e) { @@ -298,6 +302,36 @@ class SyncEngine { return (sent: sent, failed: failed, groups: groups); } + Future _removeGroup(String groupId) => db.transaction(() async { + await _assertActive(); + await removeGroupInTransaction(db, groupId); + }); + + /// A refused leave must not lock the account out of its still-active group. + /// Keep the refusal for review; a new leave replaces it after discarding. + Future _restoreRefusedDeparture(OutboxRow item) async { + if (accountId == null || item.target != OutboxTarget.member) return; + await db.transaction(() async { + await _assertActive(); + if (!await outbox.isCurrent(item)) return; + await (db.update(db.members)..where( + (m) => m.id.equals(item.targetId) & m.profileId.equals(accountId!), + )) + .write(const MembersCompanion(leftAt: Value(null))); + }); + } + + Future _groupFor(OutboxRow item) async => switch (item.target) { + OutboxTarget.group => item.targetId, + OutboxTarget.member => (await (db.select( + db.members, + )..where((t) => t.id.equals(item.targetId))).getSingleOrNull())?.groupId, + OutboxTarget.entry => (await (db.select( + db.entries, + )..where((t) => t.id.equals(item.targetId))).getSingleOrNull())?.groupId, + OutboxTarget.profile => null, + }; + /// Parks an edit the server refused as stale for a person to read, and /// rewinds the cursor to its base so the next pull brings the server's /// version (skipped while the row was dirty). Answers the group to read. @@ -458,9 +492,10 @@ class SyncEngine { ), ); await db.transaction(() async { - if (!await outbox.isCurrent(item)) return; + await _assertActive(); await (db.update(db.groups)..where((t) => t.id.equals(group.id))) .write(GroupsCompanion(seq: Value(stored.seq))); + // The creator landed in the same change; this stops a second push. if (group.seq == null) { await (db.update(db.members)..where((t) => t.id.equals(creator.id))) @@ -486,9 +521,13 @@ class SyncEngine { ), ); await db.transaction(() async { - if (!await outbox.isCurrent(item)) return; - await (db.update(db.members)..where((t) => t.id.equals(member.id))) - .write(MembersCompanion(seq: Value(stored.seq))); + await _assertActive(); + if (stored.profileId == accountId && stored.leftAt != null) { + await removeGroupInTransaction(db, member.groupId); + } else { + await (db.update(db.members)..where((t) => t.id.equals(member.id))) + .write(MembersCompanion(seq: Value(stored.seq))); + } }); return member.groupId; @@ -539,8 +578,8 @@ class SyncEngine { /// is caught up or refused. With [everything], also every group this /// account is in, and the server's answer names any it joined elsewhere. /// - /// A group the server refuses is logged and left as it is here: one group - /// it cannot read says nothing about the others. + /// Definitive membership loss removes that group's cache and queued edits. + /// Other refusals leave its cache intact and do not stop unrelated groups. Future _pull(Set groupIds, {required bool everything}) async { final cursors = await _groupCursors(); final skipped = await _unreadable(); @@ -580,7 +619,12 @@ class SyncEngine { done.add(cursor.groupId); } for (final page in answer.pages) { - applied += await applyGroupChanges(db, page, now: _clock()); + applied += await applyGroupChanges( + db, + page, + now: _clock(), + assertActive: _assertActive, + ); final since = cursors[page.groupId] ?? 0; cursors[page.groupId] = page.seq; if (page.hasMore && page.purgedAt == null && page.seq > since) { @@ -589,6 +633,11 @@ class SyncEngine { } } for (final refusal in answer.refusals) { + if (refusal.code == api.ErrorCode.notMember || + refusal.code == api.ErrorCode.groupPurged) { + await _removeGroup(refusal.groupId); + continue; + } developer.log( 'Group ${refusal.groupId} was not read: ${refusal.message}', name: 'opensplit.sync', diff --git a/lib/data/sync/wire.dart b/lib/data/sync/wire.dart index dc1e571..2782f00 100644 --- a/lib/data/sync/wire.dart +++ b/lib/data/sync/wire.dart @@ -23,7 +23,6 @@ extension GroupFromWire on api.Group { coverPhoto: coverPhoto, createdBy: createdBy, createdAt: createdAt, - archivedAt: archivedAt, seq: seq, ); } @@ -142,7 +141,6 @@ extension GroupToWire on Group { defaultCurrency: defaultCurrency, isDirect: isDirect, simplifyDebts: simplifyDebts, - archivedAt: archivedAt?.toUtc(), avatarKind: avatarKind, avatarColor: avatarColor, avatarEmoji: avatarEmoji, @@ -152,6 +150,7 @@ extension GroupToWire on Group { coverPhoto: coverPhoto, creatorId: creator.id, creatorName: creator.displayName, + baseSeq: seq, ); } @@ -160,6 +159,7 @@ extension MemberToWire on Member { displayName: displayName, upiVpa: upiVpa, leftAt: leftAt?.toUtc(), + baseSeq: seq, ); } diff --git a/lib/data/web/release_updates_stub.dart b/lib/data/web/release_updates_stub.dart index bd15332..9182d81 100644 --- a/lib/data/web/release_updates_stub.dart +++ b/lib/data/web/release_updates_stub.dart @@ -3,3 +3,6 @@ void watchForNewRelease(void Function() onReady) {} /// Never called off the web, where [watchForNewRelease] never fires. void restartIntoNewRelease() {} + +/// Only web releases are installed through a service worker. +Future updateAndRestart() async {} diff --git a/lib/data/web/release_updates_web.dart b/lib/data/web/release_updates_web.dart index 65f71d9..ab89b61 100644 --- a/lib/data/web/release_updates_web.dart +++ b/lib/data/web/release_updates_web.dart @@ -18,6 +18,7 @@ web.ServiceWorkerRegistration? _registration; /// Set once the person has asked to restart, so the switch of worker it causes /// reloads this tab rather than offering the same restart again. bool _restarting = false; +bool _newController = false; /// Calls [onReady] when a newer release has installed and is waiting for this /// tab, or once another tab has already switched to it. @@ -38,6 +39,7 @@ void watchForNewRelease(void Function() onReady) { if (_restarting) { web.window.location.reload(); } else if (controlled) { + _newController = true; onReady(); } controlled = true; @@ -61,6 +63,48 @@ void restartIntoNewRelease() { waiting.postMessage({'type': _skipWaiting}.jsify()); } +/// Checks for a release before switching workers, without clearing app data. +Future updateAndRestart() async { + if (!web.window.navigator.has('serviceWorker')) { + web.window.location.reload(); + return; + } + final container = web.window.navigator.serviceWorker; + final registration = + _registration ?? + await container.ready.toDart.timeout(const Duration(seconds: 20)); + _registration = registration; + if (registration.waiting != null || _newController) { + restartIntoNewRelease(); + return; + } + await registration.update().toDart.timeout(const Duration(seconds: 20)); + final installing = registration.installing; + if (installing != null) { + final installed = Completer(); + void check(web.Event _) { + if (installing.state == 'installed' || installing.state == 'redundant') { + if (!installed.isCompleted) installed.complete(); + } + } + + final listener = check.toJS; + installing.addEventListener('statechange', listener); + try { + check(web.Event('statechange')); + await installed.future.timeout(const Duration(seconds: 30)); + } finally { + installing.removeEventListener('statechange', listener); + } + } + if (registration.waiting == null && !_newController) { + throw StateError( + 'The new web release is not available yet. Try again shortly.', + ); + } + restartIntoNewRelease(); +} + Future _watchRegistration( web.ServiceWorkerContainer container, void Function() onReady, diff --git a/lib/domain/export/json_export.dart b/lib/domain/export/json_export.dart index c7f22e2..9d6a6ef 100644 --- a/lib/domain/export/json_export.dart +++ b/lib/domain/export/json_export.dart @@ -27,7 +27,6 @@ String groupToJson({ 'default_currency': group.defaultCurrency, 'simplify_debts': group.simplifyDebts, 'created_at': group.createdAt.toIso8601String(), - 'archived_at': group.archivedAt?.toIso8601String(), }, 'members': [ for (final member in members) diff --git a/lib/domain/models/group_event.dart b/lib/domain/models/group_event.dart index e5e33e9..a82db58 100644 --- a/lib/domain/models/group_event.dart +++ b/lib/domain/models/group_event.dart @@ -73,7 +73,7 @@ final class MemberChanged extends GroupEvent { final String? previousName; } -/// The group itself was renamed, archived or restored. +/// The group itself was renamed. final class GroupChanged extends GroupEvent { const GroupChanged({ required super.id, diff --git a/lib/main.dart b/lib/main.dart index a4bf4bd..bf07245 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'application/preferences_providers.dart'; import 'config.dart'; import 'data/web/launch_signal.dart'; import 'data/auth/session_store.dart'; +import 'data/sync/api_client.dart'; import 'presentation/app.dart'; import 'presentation/launch_hold.dart'; @@ -64,6 +65,11 @@ Future main() async { overrides: [ sharedPreferencesProvider.overrideWithValue(prefs), sessionStoreProvider.overrideWithValue(sessions), + apiAccessProvider.overrideWith((ref) { + final access = ApiAccess.persisted(prefs, baseUrl: apiBaseUrl); + ref.onDispose(access.dispose); + return access; + }), ], child: const OpenSplitApp(), ), diff --git a/lib/presentation/app.dart b/lib/presentation/app.dart index 933b192..6eb09be 100644 --- a/lib/presentation/app.dart +++ b/lib/presentation/app.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:in_app_update/in_app_update.dart'; import '../application/local_providers.dart'; +import '../application/backend_providers.dart'; import '../application/preferences_providers.dart'; import '../application/push_providers.dart'; import '../application/ledger_providers.dart'; @@ -19,6 +20,7 @@ import '../l10n/app_localizations.dart'; import 'dynamic_colors.dart'; import 'theme.dart'; import 'theme_mode.dart'; +import 'widgets/update_required_notice.dart'; class OpenSplitApp extends ConsumerStatefulWidget { const OpenSplitApp({super.key}); @@ -128,7 +130,9 @@ class _OpenSplitAppState extends ConsumerState /// for. Future _offerUpdate() async { final service = ref.read(appUpdateServiceProvider); - if (!service.isSupported || _busy) return; + if (!service.isSupported || _busy || ref.read(requiresAppUpdateProvider)) { + return; + } final last = _lastChecked; if (last != null && DateTime.now().difference(last) < _recheckAfter) return; @@ -208,7 +212,7 @@ class _OpenSplitAppState extends ConsumerState // Each screen retains standard visual reading order within its scope. builder: (context, child) => FocusTraversalGroup( policy: WidgetOrderTraversalPolicy(), - child: child!, + child: UpdateRequiredNotice(child: child!), ), ); } diff --git a/lib/presentation/feedback.dart b/lib/presentation/feedback.dart index 461c410..299f313 100644 --- a/lib/presentation/feedback.dart +++ b/lib/presentation/feedback.dart @@ -1,6 +1,27 @@ import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; /// One short buzz as an irreversible action is confirmed. void confirmedIrreversibly() { HapticFeedback.mediumImpact(); } + +/// Keeps a rejected edit visible when access or data changes during a dialog. +Future saveWithFeedback( + BuildContext context, + Future Function() save, +) async { + if (!context.mounted) return false; + try { + await save(); + return true; + } catch (error) { + if (context.mounted) { + final message = error is StateError ? error.message : '$error'; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Could not save. $message'))); + } + return false; + } +} diff --git a/lib/presentation/router.dart b/lib/presentation/router.dart index d563e2d..2cd7171 100644 --- a/lib/presentation/router.dart +++ b/lib/presentation/router.dart @@ -4,10 +4,11 @@ import 'package:animations/animations.dart'; import '../l10n/app_localizations.dart'; import 'navigation.dart'; +import 'widgets/group_access.dart'; +import 'widgets/update_required_notice.dart'; import 'screens/about_screen.dart'; import 'screens/account_screen.dart'; import 'screens/activity_screen.dart'; -import 'screens/archived_groups_screen.dart'; import 'screens/avatar_picker_screen.dart'; import 'screens/edit_profile_screen.dart'; import 'screens/entry_editor_screen.dart'; @@ -92,21 +93,20 @@ GoRouter buildRouter({ GoRoute( path: '/welcome', pageBuilder: (context, state) => const NoTransitionPage( - child: _ReadingOrderScreen(child: WelcomeScreen()), + child: _ReadingOrderScreen( + child: RequireCurrentApp(child: WelcomeScreen()), + ), ), ), // The link a friend sends. GoRoute( path: '/join/:token', builder: (context, state) => _ReadingOrderScreen( - child: JoinScreen(token: state.pathParameters['token']!), + child: RequireCurrentApp( + child: JoinScreen(token: state.pathParameters['token']!), + ), ), ), - GoRoute( - path: '/archived', - builder: (context, state) => - const _ReadingOrderScreen(child: ArchivedGroupsScreen()), - ), // Above the shell rather than inside Settings' branch, so it arrives // with a back arrow instead of a menu button -- it is a screen reached // from a destination, not a destination. @@ -120,51 +120,64 @@ GoRouter buildRouter({ GoRoute( path: '/account/edit', builder: (context, state) => _ReadingOrderScreen( - child: EditProfileScreen( - focus: - ProfileField.values - .asNameMap()[state.uri.queryParameters['field']] ?? - ProfileField.name, + child: RequireCurrentApp( + child: EditProfileScreen( + focus: + ProfileField.values + .asNameMap()[state.uri.queryParameters['field']] ?? + ProfileField.name, + ), ), ), ), GoRoute( path: '/account/save', - builder: (context, state) => - const _ReadingOrderScreen(child: SaveAccountScreen()), + builder: (context, state) => const _ReadingOrderScreen( + child: RequireCurrentApp(child: SaveAccountScreen()), + ), ), GoRoute( path: '/account/picture', - builder: (context, state) => - const _ReadingOrderScreen(child: AvatarPickerScreen.profile()), + builder: (context, state) => const _ReadingOrderScreen( + child: RequireCurrentApp(child: AvatarPickerScreen.profile()), + ), ), GoRoute( path: '/g/:groupId', - builder: (context, state) => _ReadingOrderScreen( + builder: (context, state) => _GroupScreen( + groupId: state.pathParameters['groupId']!, child: GroupDetailScreen(groupId: state.pathParameters['groupId']!), ), routes: [ GoRoute( path: 'add', - builder: (context, state) => _ReadingOrderScreen( - child: EntryEditorScreen(groupId: state.pathParameters['groupId']!), + builder: (context, state) => _GroupScreen( + groupId: state.pathParameters['groupId']!, + child: RequireCurrentApp( + child: EntryEditorScreen( + groupId: state.pathParameters['groupId']!, + ), + ), ), ), GoRoute( path: 'activity', - builder: (context, state) => _ReadingOrderScreen( + builder: (context, state) => _GroupScreen( + groupId: state.pathParameters['groupId']!, child: ActivityScreen(groupId: state.pathParameters['groupId']!), ), ), GoRoute( path: 'insights', - builder: (context, state) => _ReadingOrderScreen( + builder: (context, state) => _GroupScreen( + groupId: state.pathParameters['groupId']!, child: InsightsScreen(groupId: state.pathParameters['groupId']!), ), ), GoRoute( path: 'settings', - builder: (context, state) => _ReadingOrderScreen( + builder: (context, state) => _GroupScreen( + groupId: state.pathParameters['groupId']!, child: GroupSettingsScreen( groupId: state.pathParameters['groupId']!, ), @@ -172,35 +185,43 @@ GoRouter buildRouter({ ), GoRoute( path: 'members', - builder: (context, state) => _ReadingOrderScreen( + builder: (context, state) => _GroupScreen( + groupId: state.pathParameters['groupId']!, child: MembersScreen(groupId: state.pathParameters['groupId']!), ), ), GoRoute( path: 'picture', - builder: (context, state) => _ReadingOrderScreen( - child: AvatarPickerScreen.group( - groupId: state.pathParameters['groupId']!, + builder: (context, state) => _GroupScreen( + groupId: state.pathParameters['groupId']!, + child: RequireCurrentApp( + child: AvatarPickerScreen.group( + groupId: state.pathParameters['groupId']!, + ), ), ), ), GoRoute( path: 'settle', - builder: (context, state) => _ReadingOrderScreen( - child: SettleUpScreen( - groupId: state.pathParameters['groupId']!, - fromMemberId: state.uri.queryParameters['from'], - toMemberId: state.uri.queryParameters['to'], - amountMinor: int.tryParse( - state.uri.queryParameters['amount'] ?? '', + builder: (context, state) => _GroupScreen( + groupId: state.pathParameters['groupId']!, + child: RequireCurrentApp( + child: SettleUpScreen( + groupId: state.pathParameters['groupId']!, + fromMemberId: state.uri.queryParameters['from'], + toMemberId: state.uri.queryParameters['to'], + amountMinor: int.tryParse( + state.uri.queryParameters['amount'] ?? '', + ), + currency: state.uri.queryParameters['currency'], ), - currency: state.uri.queryParameters['currency'], ), ), ), GoRoute( path: 'e/:entryId', - builder: (context, state) => _ReadingOrderScreen( + builder: (context, state) => _GroupScreen( + groupId: state.pathParameters['groupId']!, child: EntryEditorScreen( groupId: state.pathParameters['groupId']!, entryId: state.pathParameters['entryId'], @@ -467,3 +488,16 @@ class _NavigationDrawer extends StatelessWidget { ); } } + +/// A shared access boundary for every group route, including direct links. +class _GroupScreen extends StatelessWidget { + const _GroupScreen({required this.groupId, required this.child}); + + final String groupId; + final Widget child; + + @override + Widget build(BuildContext context) => _ReadingOrderScreen( + child: GroupAccess(groupId: groupId, child: child), + ); +} diff --git a/lib/presentation/screens/account_screen.dart b/lib/presentation/screens/account_screen.dart index 5e6ccb8..ccbfd74 100644 --- a/lib/presentation/screens/account_screen.dart +++ b/lib/presentation/screens/account_screen.dart @@ -1,3 +1,4 @@ +import '../../application/backend_providers.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -238,7 +239,9 @@ class _AccountScreenState extends ConsumerState { subtitle: const Text( 'Removes this device\'s copy. Sign back in to get it again.', ), - onTap: _signOut, + onTap: ref.watch(requiresAppUpdateProvider) + ? null + : _signOut, ) // Said out loud rather than left as a gap. else @@ -278,7 +281,9 @@ class _AccountScreenState extends ConsumerState { child: CircularProgressIndicator(strokeWidth: 2), ) : null, - onTap: _deleting ? null : _deleteAccount, + onTap: _deleting || ref.watch(requiresAppUpdateProvider) + ? null + : _deleteAccount, ), ], ), @@ -291,7 +296,7 @@ class _AccountScreenState extends ConsumerState { } /// Who this is, as the people in your groups see them. -class _Identity extends StatelessWidget { +class _Identity extends ConsumerWidget { const _Identity({ required this.id, required this.avatar, @@ -307,7 +312,7 @@ class _Identity extends StatelessWidget { final bool isGuest; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final scheme = theme.colorScheme; final named = name?.trim() ?? ''; @@ -316,7 +321,9 @@ class _Identity extends StatelessWidget { margin: EdgeInsets.zero, child: InkWell( borderRadius: BorderRadius.circular(12), - onTap: () => _edit(context, ProfileField.name), + onTap: ref.watch(requiresAppUpdateProvider) + ? null + : () => _edit(context, ProfileField.name), child: Padding( padding: const EdgeInsets.all(20), child: Row( @@ -324,7 +331,9 @@ class _Identity extends StatelessWidget { Tooltip( message: 'Change picture', child: InkResponse( - onTap: () => context.push('/account/picture'), + onTap: ref.watch(requiresAppUpdateProvider) + ? null + : () => context.push('/account/picture'), radius: 36, child: AvatarView( avatar: avatar, @@ -360,7 +369,9 @@ class _Identity extends StatelessWidget { IconButton( icon: const Icon(Icons.edit_outlined), tooltip: 'Edit profile', - onPressed: () => _edit(context, ProfileField.name), + onPressed: ref.watch(requiresAppUpdateProvider) + ? null + : () => _edit(context, ProfileField.name), ), ], ), @@ -371,11 +382,11 @@ class _Identity extends StatelessWidget { } /// The one thing a guest should do, said once and with one button. -class _SaveAccountCard extends StatelessWidget { +class _SaveAccountCard extends ConsumerWidget { const _SaveAccountCard(); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final scheme = theme.colorScheme; final onCard = scheme.onSecondaryContainer; @@ -400,7 +411,9 @@ class _SaveAccountCard extends StatelessWidget { ), const SizedBox(height: 16), FilledButton( - onPressed: () => context.push('/account/save'), + onPressed: ref.watch(requiresAppUpdateProvider) + ? null + : () => context.push('/account/save'), child: const Text('Save my account'), ), ], @@ -434,7 +447,7 @@ class _SectionHeader extends StatelessWidget { } /// One stored value, read-only, opening the editor on its own field. -class _ProfileRow extends StatelessWidget { +class _ProfileRow extends ConsumerWidget { const _ProfileRow({ required this.icon, required this.label, @@ -452,7 +465,7 @@ class _ProfileRow extends StatelessWidget { final ProfileField field; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final scheme = Theme.of(context).colorScheme; final shown = value?.trim() ?? ''; return ListTile( @@ -463,7 +476,9 @@ class _ProfileRow extends StatelessWidget { style: shown.isEmpty ? TextStyle(color: scheme.onSurfaceVariant) : null, ), trailing: const Icon(Icons.chevron_right), - onTap: () => _edit(context, field), + onTap: ref.watch(requiresAppUpdateProvider) + ? null + : () => _edit(context, field), ); } } diff --git a/lib/presentation/screens/activity_screen.dart b/lib/presentation/screens/activity_screen.dart index 1236624..3fd2813 100644 --- a/lib/presentation/screens/activity_screen.dart +++ b/lib/presentation/screens/activity_screen.dart @@ -121,16 +121,10 @@ class _Line extends StatelessWidget { : '$actor renamed $previousName to $displayName', ), - GroupChanged(kind: EventKind.groupRenamed, :final name) => ( + GroupChanged(:final name) => ( Icons.drive_file_rename_outline, '$actor renamed the group to $name', ), - GroupChanged(kind: EventKind.groupArchived) => ( - Icons.inventory_2_outlined, - '$actor archived the group', - ), - GroupChanged() => (Icons.unarchive_outlined, '$actor restored the group'), - LinkChanged(kind: EventKind.linkCreated) => ( Icons.link, '$actor created an invite link', diff --git a/lib/presentation/screens/archived_groups_screen.dart b/lib/presentation/screens/archived_groups_screen.dart deleted file mode 100644 index f7c413a..0000000 --- a/lib/presentation/screens/archived_groups_screen.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:material_ui/material_ui.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:intl/intl.dart'; - -import '../../application/ledger_providers.dart'; -import '../../application/local_providers.dart'; -import '../../data/repositories/drift_group_repository.dart'; -import '../navigation.dart'; -import '../widgets/empty_state.dart'; -import '../widgets/page_body.dart'; - -/// Groups that have been put away or left, and the way back. -class ArchivedGroupsScreen extends ConsumerWidget { - const ArchivedGroupsScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final listings = ref.watch(groupListingsProvider).value; - final archived = [ - for (final listing in listings ?? const []) - if (!listing.isCurrent) listing, - ]; - - return Scaffold( - appBar: AppBar( - leading: BackButton(onPressed: () => goBack(context, '/')), - title: const Text('Archived groups'), - ), - body: PageBody( - child: archived.isEmpty - ? const _Empty() - : ListView.separated( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), - itemCount: archived.length + 1, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (context, index) => index == 0 - ? const _Explanation() - : _ArchivedTile(listing: archived[index - 1]), - ), - ), - ); - } -} - -class _Explanation extends StatelessWidget { - const _Explanation(); - - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - 'These are out of the way, not gone. Everything in them still adds up, ' - 'and adding an expense brings one back by itself. Groups you left are ' - 'here too, read-only: a link from somebody still in one brings you ' - 'back.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ); -} - -class _ArchivedTile extends ConsumerWidget { - const _ArchivedTile({required this.listing}); - - final GroupListing listing; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final scheme = Theme.of(context).colorScheme; - final group = listing.group; - - return Card.outlined( - child: ListTile( - onTap: () => context.push('/g/${group.id}'), - leading: CircleAvatar( - backgroundColor: scheme.surfaceContainerHighest, - child: Icon( - Icons.inventory_2_outlined, - color: scheme.onSurfaceVariant, - ), - ), - title: Text(group.name), - subtitle: Text( - listing.hasLeft - // A link from somebody still in it is the way back, not a button. - ? 'You left · read-only' - : 'Archived ${DateFormat.yMMMd().format(group.archivedAt!.toLocal())}', - ), - trailing: listing.hasLeft - ? null - : TextButton( - onPressed: () => ref - .read(groupRepositoryProvider) - .setArchived(group.id, archived: false), - child: const Text('Restore'), - ), - ), - ); - } -} - -class _Empty extends StatelessWidget { - const _Empty(); - - @override - Widget build(BuildContext context) => const EmptyState( - icon: Icons.inventory_2_outlined, - title: 'Nothing is archived', - message: - 'Archiving a group puts it out of the way without deleting any of ' - 'it. Nothing here has been put away yet.', - ); -} diff --git a/lib/presentation/screens/avatar_picker_screen.dart b/lib/presentation/screens/avatar_picker_screen.dart index 2b4a974..603d824 100644 --- a/lib/presentation/screens/avatar_picker_screen.dart +++ b/lib/presentation/screens/avatar_picker_screen.dart @@ -14,6 +14,8 @@ import '../widgets/shared_element.dart'; import '../widgets/page_body.dart'; import '../widgets/sync_status_notice.dart'; +import '../widgets/group_access.dart'; + /// Chooses what stands for you, or for a group: initials, an emoji or an /// icon, on a hue. /// @@ -94,6 +96,13 @@ class _AvatarPickerScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final groupId = widget.groupId; + if (groupId != null) { + final ledger = ref.watch(groupLedgerProvider(groupId)); + if (ledger != null && !ledger.canEdit) { + return const GroupUnavailableScreen(); + } + } final subject = _subject(); if (subject != null && _original == null) { _original = subject.avatar; diff --git a/lib/presentation/screens/entry_editor_screen.dart b/lib/presentation/screens/entry_editor_screen.dart index 41429c7..2bb3595 100644 --- a/lib/presentation/screens/entry_editor_screen.dart +++ b/lib/presentation/screens/entry_editor_screen.dart @@ -22,6 +22,8 @@ import '../widgets/page_body.dart'; import '../widgets/remainder_field.dart'; import 'settle_up_screen.dart'; +import '../widgets/group_access.dart'; + /// Creates or edits an expense. /// /// Holds no state of its own: what has been typed lives in [EntryForm], and @@ -37,7 +39,12 @@ class EntryEditorScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final ledger = ref.watch(groupLedgerProvider(groupId)); - final entry = ledger?.entries.where((e) => e.id == entryId).firstOrNull; + final entry = entryId == null + ? null + : ref.watch(entryRecordProvider(entryId!)).value; + if (ledger != null && ledger.me?.isActive != true) { + return const GroupUnavailableScreen(); + } if (entry?.row.kind == EntryKind.settlement) { return SettleUpScreen(groupId: groupId, entryId: entryId); } @@ -145,12 +152,18 @@ class _Editor extends ConsumerWidget { final currencies = ref.watch(currenciesProvider).value ?? const {}; final currency = currencies[form.currencyCode]; final textTheme = Theme.of(context).textTheme; - final ready = !form.saving && currency != null; + final ready = ledger.canEdit && !form.saving && currency != null; return Scaffold( appBar: AppBar( leading: CloseButton(onPressed: () => goBack(context, _groupPath)), - title: Text(form.isEditing ? 'Edit expense' : 'Add expense'), + title: Text( + !ledger.canEdit + ? 'Expense' + : form.isEditing + ? 'Edit expense' + : 'Add expense', + ), actions: [ if (form.isEditing) IconButton( @@ -176,123 +189,145 @@ class _Editor extends ConsumerWidget { child: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 48), children: [ - AmountCard( - heroTag: form.editing == null - ? null - : ('entry-amount', form.editing!.id), - currency: currency, - currencyCode: form.currencyCode, - amount: form.amount, - autofocus: !form.isEditing, - onAmountChanged: controller.setAmount, - onCurrencyChanged: controller.chooseCurrency, - ), - if (currency != null && - currency.code != ledger.group.defaultCurrency) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - '${currency.code} balances stay separate.', - style: textTheme.bodySmall, - ), - ), - const SizedBox(height: 16), - SharedText( - tag: ( - 'entry-description', - form.editing?.id ?? ('draft', ledger.group.id), - ), - text: form.description, - style: textTheme.bodyLarge!.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - child: TextFormField( - initialValue: form.description, - textCapitalization: TextCapitalization.sentences, - decoration: const InputDecoration( - labelText: 'What was it?', - hintText: 'Dinner at Toit', - ), - onChanged: controller.setDescription, - ), - ), - const SizedBox(height: 16), - // The details most expenses leave as they are, as chips rather - // than a field each. - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _CategoryChip( - heroTag: form.editing == null - ? null - : ('entry-category', form.editing!.id), - value: form.categoryId, - onChanged: controller.chooseCategory, - ), - ActionChip( - avatar: const Icon(Icons.event_outlined), - label: Text(_dayName(form.day)), - tooltip: 'Change the day', - onPressed: () => _pickDay(context), - ), - ], - ), - const SizedBox(height: 24), - _EditorCard( - child: _PayerSection( - ledger: ledger, - currency: currency, - form: form, - controller: controller, - ), - ), - const SizedBox(height: 16), - _EditorCard( - child: _SplitSection( - ledger: ledger, - currency: currency, - form: form, - controller: controller, - ), - ), - const SizedBox(height: 16), - ExpansionTile( - title: const Text('Time of expense'), - subtitle: Text( - form.occurredAt == null - ? 'Date only' - : DateFormat.jm().format(form.occurredAt!.toLocal()), - ), - leading: const Icon(Icons.schedule_outlined), - tilePadding: const EdgeInsets.symmetric(horizontal: 16), - childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - expandedCrossAxisAlignment: CrossAxisAlignment.start, - children: [ - _TimeChip( - shown: form.occurredAt?.toLocal(), - onPick: device == null - ? null - : () => _pickTime(context, device), - onClear: controller.clearTime, - ), - ], - ), - if (form.error case final error?) ...[ - const SizedBox(height: 16), - Card.filled( - color: Theme.of(context).colorScheme.errorContainer, - child: Padding( - padding: const EdgeInsets.all(12), - child: Text( - error, - style: textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onErrorContainer, + ExcludeFocus( + excluding: !ledger.canEdit, + child: AbsorbPointer( + absorbing: !ledger.canEdit, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AmountCard( + heroTag: form.editing == null + ? null + : ('entry-amount', form.editing!.id), + currency: currency, + currencyCode: form.currencyCode, + amount: form.amount, + autofocus: !form.isEditing, + onAmountChanged: controller.setAmount, + onCurrencyChanged: controller.chooseCurrency, + ), + if (currency != null && + currency.code != ledger.group.defaultCurrency) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '${currency.code} balances stay separate.', + style: textTheme.bodySmall, + ), + ), + const SizedBox(height: 16), + SharedText( + tag: ( + 'entry-description', + form.editing?.id ?? ('draft', ledger.group.id), + ), + text: form.description, + style: textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + child: TextFormField( + initialValue: form.description, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'What was it?', + hintText: 'Dinner at Toit', + ), + onChanged: controller.setDescription, + ), + ), + const SizedBox(height: 16), + // The details most expenses leave as they are, as chips rather + // than a field each. + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _CategoryChip( + heroTag: form.editing == null + ? null + : ('entry-category', form.editing!.id), + value: form.categoryId, + onChanged: controller.chooseCategory, + ), + ActionChip( + avatar: const Icon(Icons.event_outlined), + label: Text(_dayName(form.day)), + tooltip: 'Change the day', + onPressed: () => _pickDay(context), + ), + ], + ), + const SizedBox(height: 24), + _EditorCard( + child: _PayerSection( + ledger: ledger, + currency: currency, + form: form, + controller: controller, + ), + ), + const SizedBox(height: 16), + _EditorCard( + child: _SplitSection( + ledger: ledger, + currency: currency, + form: form, + controller: controller, + ), ), - ), + const SizedBox(height: 16), + ExpansionTile( + title: const Text('Time of expense'), + subtitle: Text( + form.occurredAt == null + ? 'Date only' + : DateFormat.jm().format( + form.occurredAt!.toLocal(), + ), + ), + leading: const Icon(Icons.schedule_outlined), + tilePadding: const EdgeInsets.symmetric( + horizontal: 16, + ), + childrenPadding: const EdgeInsets.fromLTRB( + 16, + 0, + 16, + 16, + ), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + _TimeChip( + shown: form.occurredAt?.toLocal(), + onPick: device == null + ? null + : () => _pickTime(context, device), + onClear: controller.clearTime, + ), + ], + ), + if (form.error case final error?) ...[ + const SizedBox(height: 16), + Card.filled( + color: Theme.of(context).colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + error, + style: textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onErrorContainer, + ), + ), + ), + ), + ], + ], ), ), - ], + ), if (form.editing case final editing?) ...[ const SizedBox(height: 32), EntryHistory(entryId: editing.id, ledger: ledger), diff --git a/lib/presentation/screens/group_detail_screen.dart b/lib/presentation/screens/group_detail_screen.dart index 942659f..1e4c931 100644 --- a/lib/presentation/screens/group_detail_screen.dart +++ b/lib/presentation/screens/group_detail_screen.dart @@ -26,6 +26,7 @@ import '../widgets/group_pane.dart'; import '../widgets/group_standing.dart'; import '../widgets/offline_indicator.dart'; import '../widgets/page_body.dart'; +import '../widgets/group_access.dart'; import '../widgets/segmented_list.dart'; import '../widgets/sync_refresh_button.dart'; import '../widgets/sync_status_notice.dart'; @@ -69,15 +70,19 @@ class GroupDetailScreen extends ConsumerWidget { ); } + if (ledger.me?.isActive != true) return const GroupUnavailableScreen(); + final wide = MediaQuery.sizeOf(context).width >= _wideBreakpoint; final scaffold = Scaffold( - floatingActionButton: FloatingActionButton.extended( - heroTag: null, - onPressed: () => context.push('/g/$groupId/add'), - icon: const Icon(Icons.add), - label: const Text('Add expense'), - ), + floatingActionButton: !ledger.canEdit + ? null + : FloatingActionButton.extended( + heroTag: null, + onPressed: () => context.push('/g/$groupId/add'), + icon: const Icon(Icons.add), + label: const Text('Add expense'), + ), body: NestedScrollView( headerSliverBuilder: (context, scrolled) => [ SliverOverlapAbsorber( @@ -154,11 +159,12 @@ class _GroupAppBar extends StatelessWidget { actions: [ const OfflineIndicator(), if (kIsWeb) SyncRefreshButton.group(groupId), - IconButton( - tooltip: 'Settle up', - onPressed: () => open('settle'), - icon: const Icon(Icons.handshake_outlined), - ), + if (ledger.canEdit) + IconButton( + tooltip: 'Settle up', + onPressed: () => open('settle'), + icon: const Icon(Icons.handshake_outlined), + ), IconButton( tooltip: 'People', onPressed: () => open('members'), @@ -290,7 +296,9 @@ class _GroupHeader extends ConsumerWidget { child: Tooltip( message: 'Group picture', child: InkResponse( - onTap: () => context.push('/g/${group.id}/picture'), + onTap: ledger.canEdit + ? () => context.push('/g/${group.id}/picture') + : null, radius: _avatarRadius + ring, child: CircleAvatar( radius: _avatarRadius + ring, @@ -359,9 +367,9 @@ class _EntriesPane extends ConsumerWidget { storageKey: 'expenses', slivers: [ if (ledger.entries.isEmpty) - const SliverFillRemaining( + SliverFillRemaining( hasScrollBody: false, - child: _EmptyEntries(), + child: _EmptyEntries(canEdit: ledger.canEdit), ) else SliverList.builder( @@ -559,12 +567,16 @@ class _EntryTile extends StatelessWidget { } class _EmptyEntries extends StatelessWidget { - const _EmptyEntries(); + const _EmptyEntries({required this.canEdit}); + + final bool canEdit; @override - Widget build(BuildContext context) => const EmptyState( + Widget build(BuildContext context) => EmptyState( icon: Icons.receipt_long_outlined, - title: 'Nothing yet', - message: 'Add the first expense and balances appear straight away.', + title: canEdit ? 'Nothing yet' : 'No expenses', + message: canEdit + ? 'Add the first expense and balances appear straight away.' + : 'There are no active expenses in this saved history.', ); } diff --git a/lib/presentation/screens/group_list_screen.dart b/lib/presentation/screens/group_list_screen.dart index a422734..636de63 100644 --- a/lib/presentation/screens/group_list_screen.dart +++ b/lib/presentation/screens/group_list_screen.dart @@ -1,3 +1,4 @@ +import '../../application/backend_providers.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -20,7 +21,6 @@ import '../widgets/link_account_prompt.dart'; import '../widgets/offline_indicator.dart'; import '../widgets/page_body.dart'; import '../widgets/pull_to_sync.dart'; -import '../widgets/segmented_list.dart'; import '../widgets/sync_refresh_button.dart'; import '../widgets/sync_status_notice.dart'; import '../widgets/unsynced_changes_banner.dart'; @@ -30,10 +30,6 @@ class GroupListScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Archived and left groups are pulled in here rather than queried - // separately, so the list and the "archived" row at the bottom of it are - // two readings of one stream and cannot disagree about which group is - // where. final source = groupListingsProvider; final groupsAsync = ref.watch(source); @@ -42,7 +38,6 @@ class GroupListScreen extends ConsumerWidget { for (final listing in all) if (listing.isCurrent) listing.group, ]; - final archived = all.length - groups.length; return DestinationScaffold( titleWidget: const BrandLockup(), @@ -60,7 +55,9 @@ class GroupListScreen extends ConsumerWidget { // device with no connection. floatingActionButton: FloatingActionButton.extended( heroTag: null, - onPressed: ref.watch(referenceDataProvider).value ?? false + onPressed: + !ref.watch(requiresAppUpdateProvider) && + (ref.watch(referenceDataProvider).value ?? false) ? () => showCreateGroupSheet(context) : null, icon: const Icon(Icons.group_add_outlined), @@ -77,10 +74,7 @@ class GroupListScreen extends ConsumerWidget { child: _Message(text: 'Could not load groups.\n$error'), ), ], - AsyncValue(hasValue: true) => _GroupList.slivers( - groups: groups, - archivedCount: archived, - ), + AsyncValue(hasValue: true) => _GroupList.slivers(groups: groups), // Nothing, rather than placeholder cards: at launch the splash covers // this, and afterwards a local read is too quick to be worth drawing. _ => const [LaunchPlaceholder(child: SliverToBoxAdapter())], @@ -93,10 +87,7 @@ class GroupListScreen extends ConsumerWidget { abstract final class _GroupList { static const _noticeGap = EdgeInsets.only(bottom: 8); - static List slivers({ - required List groups, - required int archivedCount, - }) => [ + static List slivers({required List groups}) => [ // The notices, each of which renders as nothing until it has something to // say. SliverPadding( @@ -128,35 +119,11 @@ abstract final class _GroupList { ), ), ), - if (archivedCount > 0) - SliverToBoxAdapter(child: _ArchivedRow(count: archivedCount)), const SliverPadding(padding: EdgeInsets.only(bottom: 96)), ]; } /// The way to the groups that are no longer in this list. -class _ArchivedRow extends StatelessWidget { - const _ArchivedRow({required this.count}); - - final int count; - - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.only(top: 16), - // A group of one, apart from the groups above it. - child: SegmentedList( - children: [ - ListTile( - onTap: () => context.push('/archived'), - leading: const Icon(Icons.inventory_2_outlined), - title: Text('Archived groups ($count)'), - trailing: const Icon(Icons.chevron_right), - ), - ], - ), - ); -} - class _GroupTile extends ConsumerWidget { const _GroupTile({required this.group}); diff --git a/lib/presentation/screens/group_settings_screen.dart b/lib/presentation/screens/group_settings_screen.dart index e5242a0..fbd9023 100644 --- a/lib/presentation/screens/group_settings_screen.dart +++ b/lib/presentation/screens/group_settings_screen.dart @@ -15,9 +15,10 @@ import '../navigation.dart'; import '../widgets/avatar_view.dart'; import '../widgets/export_button.dart'; import '../widgets/page_body.dart'; +import '../widgets/group_access.dart'; import '../widgets/segmented_list.dart'; -/// Renaming, archiving and leaving. +/// Group settings and departure. class GroupSettingsScreen extends ConsumerStatefulWidget { const GroupSettingsScreen({super.key, required this.groupId}); @@ -38,16 +39,20 @@ class _GroupSettingsScreenState extends ConsumerState { context: context, builder: (context) => _RenameDialog(current: ledger.group.name), ); - if (name == null || name == ledger.group.name) return; + if (name == null || name == ledger.group.name || !mounted) return; setState(() => _busy = true); try { - // Read again: the row may have changed while the dialog was open. - final groups = ref.read(groupRepositoryProvider); - final group = await groups.getGroup(widget.groupId); - if (group == null) return; - await groups.updateGroup(group.copyWith(name: name)); - if (mounted) { + final saved = await saveWithFeedback(context, () async { + // Read again: the row may have changed while the dialog was open. + final groups = ref.read(groupRepositoryProvider); + final group = await groups.getGroup(widget.groupId); + if (group == null) { + throw StateError('This group is no longer available.'); + } + await groups.updateGroup(group.copyWith(name: name)); + }); + if (saved && mounted) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Renamed'))); @@ -57,20 +62,6 @@ class _GroupSettingsScreenState extends ConsumerState { } } - Future _setArchived( - GroupLedger ledger, { - required bool archived, - }) async { - setState(() => _busy = true); - try { - await ref - .read(groupRepositoryProvider) - .setArchived(widget.groupId, archived: archived); - } finally { - if (mounted) setState(() => _busy = false); - } - } - /// What this member still owes or is owed, per currency, in words. List _outstanding(GroupLedger ledger, Member me) { // read, not watch: this is called from a button handler, not from build, @@ -93,17 +84,36 @@ class _GroupSettingsScreenState extends ConsumerState { Future _leave(GroupLedger ledger, Member me) async { final debts = _outstanding(ledger, me); if (debts.isNotEmpty) return _settleFirst(debts); + final lastAccount = !ledger.members.any( + (member) => member.id != me.id && member.profileId != null, + ); + if (lastAccount && !ledger.isSettled) { + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Settle the group first'), + content: const Text( + 'You are the last account holder. All balances, including people without accounts, must be settled before you can leave and delete the group.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ); + return; + } final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Leave this group?'), - content: const Text( - 'You stop getting updates, and you will not appear in new ' - 'expenses.\n\n' - 'Everything you have already paid for or owed stays exactly as it ' - 'is. This device keeps a read-only copy with your archived groups, ' - 'and an invite link brings you back to the same place.', + content: Text( + lastAccount + ? 'You are the last account holder. Leaving permanently deletes this group and its expenses. Export a copy first if you need one.' + : 'Leaving ends your access on all your devices. Your expenses stay in the group for the remaining members. Export a copy first if you need one.', ), actions: [ TextButton( @@ -122,16 +132,25 @@ class _GroupSettingsScreenState extends ConsumerState { ); if (!(confirmed ?? false) || !mounted) return; + // Membership changes can unmount this screen before its save completes. + final groups = ref.read(groupRepositoryProvider); + final sync = ref.read(syncControllerProvider.notifier); + final router = GoRouter.of(context); + final groupId = widget.groupId; setState(() => _busy = true); try { - await ref.read(groupRepositoryProvider).leaveGroup(memberId: me.id); + final saved = await saveWithFeedback( + context, + () => groups.leaveGroup(memberId: me.id), + ); + if (!saved) return; // Best effort: the leave is already recorded locally and queued, so an // unreachable server only delays it. - await ref.read(syncControllerProvider.notifier).syncGroup(widget.groupId); + router.go('/'); + await sync.syncGroup(groupId); } finally { if (mounted) setState(() => _busy = false); } - if (mounted) goBack(context, '/'); } /// Says why leaving has to wait, and offers the way to get there. @@ -170,7 +189,7 @@ class _GroupSettingsScreenState extends ConsumerState { return Scaffold(appBar: AppBar(leading: const BackButton())); } - final archived = ledger.group.archivedAt != null; + if (ledger.me?.isActive != true) return const GroupUnavailableScreen(); final me = ledger.me; return Scaffold( @@ -191,7 +210,9 @@ class _GroupSettingsScreenState extends ConsumerState { title: const Text('Name'), subtitle: Text(ledger.group.name), trailing: const Icon(Icons.chevron_right), - onTap: _busy ? null : () => _rename(ledger), + onTap: _busy || !ledger.canEdit + ? null + : () => _rename(ledger), ), ListTile( leading: AvatarView( @@ -206,7 +227,9 @@ class _GroupSettingsScreenState extends ConsumerState { InitialsAvatar() || PhotoAvatar() => 'Its initials', }), trailing: const Icon(Icons.chevron_right), - onTap: () => context.push('/g/${widget.groupId}/picture'), + onTap: ledger.canEdit + ? () => context.push('/g/${widget.groupId}/picture') + : null, ), ], ), @@ -215,30 +238,22 @@ class _GroupSettingsScreenState extends ConsumerState { children: [ SwitchListTile( value: ledger.group.simplifyDebts, - onChanged: _busy + onChanged: _busy || !ledger.canEdit ? null - : (value) => ref - .read(groupRepositoryProvider) - .updateGroup( - ledger.group.copyWith(simplifyDebts: value), - ), + : (value) => saveWithFeedback( + context, + () => ref + .read(groupRepositoryProvider) + .updateGroup( + ledger.group.copyWith(simplifyDebts: value), + ), + ), title: const Text('Suggest the fewest payments'), subtitle: const Text( 'Fewer payments, same balances. You may pay someone ' 'you did not directly owe.', ), ), - - SwitchListTile( - value: archived, - onChanged: _busy - ? null - : (value) => _setArchived(ledger, archived: value), - title: const Text('Archive'), - subtitle: const Text( - 'Hide this group without deleting its expenses.', - ), - ), ], ), const SizedBox(height: 24), @@ -257,9 +272,9 @@ class _GroupSettingsScreenState extends ConsumerState { ), const SizedBox(height: 24), - if (me == null) + if (!ledger.canEdit || me == null) Text( - 'You are not a member of this group.', + 'Update OpenSplit to make changes.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: scheme.onSurfaceVariant, ), @@ -273,14 +288,16 @@ class _GroupSettingsScreenState extends ConsumerState { 'Leave group', style: TextStyle(color: scheme.error), ), - subtitle: const Text('Past expenses stay in the group.'), + subtitle: const Text( + 'Export any records you want to keep first.', + ), onTap: _busy ? null : () => _leave(ledger, me), ), ], ), const SizedBox(height: 8), Text( - 'Groups with expenses cannot be deleted. Archive them instead.', + 'Leaving ends your access. Export a copy beforehand if you need it.', style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), diff --git a/lib/presentation/screens/members_screen.dart b/lib/presentation/screens/members_screen.dart index 5b3e31d..320ace8 100644 --- a/lib/presentation/screens/members_screen.dart +++ b/lib/presentation/screens/members_screen.dart @@ -28,12 +28,14 @@ class MembersScreen extends ConsumerWidget { leading: BackButton(onPressed: () => goBack(context, '/g/$groupId')), title: const Text('People'), ), - floatingActionButton: FloatingActionButton.extended( - heroTag: null, - onPressed: () => _addMember(context, ref), - icon: const Icon(Icons.person_add_outlined), - label: const Text('Add person'), - ), + floatingActionButton: ledger?.canEdit != true + ? null + : FloatingActionButton.extended( + heroTag: null, + onPressed: () => _addMember(context, ref), + icon: const Icon(Icons.person_add_outlined), + label: const Text('Add person'), + ), body: PageBody( child: ledger == null ? const SizedBox.shrink() @@ -42,19 +44,23 @@ class MembersScreen extends ConsumerWidget { children: [ // Above the list rather than in a menu, because it is what // somebody who has just made a group came here to do. - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: FilledButton.tonalIcon( - onPressed: () => showGroupLinkSheet(context, groupId), - icon: const Icon(Icons.link), - label: const Text('Share an invite link'), + if (ledger.canEdit) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: FilledButton.tonalIcon( + onPressed: () => showGroupLinkSheet(context, groupId), + icon: const Icon(Icons.link), + label: const Text('Share an invite link'), + ), ), - ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: SegmentedList( children: [ - for (final member in ledger.members) + for (final member in [ + ...ledger.members, + ...ledger.pastMembers, + ]) ListTile( leading: MemberAvatar( ledger: ledger, @@ -65,7 +71,9 @@ class MembersScreen extends ConsumerWidget { (member.id == ledger.me?.id ? ' (you)' : ''), ), subtitle: Text( - ledger.upiOf(member) != null + !member.isActive + ? 'Left the group' + : ledger.upiOf(member) != null // The handle is the useful thing to see at a glance // here: it is what makes settling with this person // one tap instead of a chat message asking for it. @@ -76,83 +84,90 @@ class MembersScreen extends ConsumerWidget { ? 'Added by someone here — no account yet' : 'Member', ), - trailing: PopupMenuButton( - onSelected: (action) => switch (action) { - 'invite' => showInviteSheet( - context, - ref, - member, - ), - 'me' => context.go('/account'), - 'rename' => _rename(context, ref, member), - 'upi' => _setUpi(context, ref, member), - 'remove' => _remove( - context, - ref, - member, - ledger, - ), - _ => null, - }, - // Only placeholders can be renamed or given a payment - // handle here, and that is not a permission rule — it - // is what the fields are. - itemBuilder: (context) { - final mine = member.id == ledger.me?.id; + trailing: !ledger.canEdit || !member.isActive + ? null + : PopupMenuButton( + onSelected: (action) => switch (action) { + 'invite' => showInviteSheet( + context, + ref, + member, + ), + 'me' => context.go('/account'), + 'rename' => _rename(context, ref, member), + 'upi' => _setUpi(context, ref, member), + 'remove' => _remove( + context, + ref, + member, + ledger, + ), + _ => null, + }, + // Only placeholders can be renamed or given a payment + // handle here, and that is not a permission rule — it + // is what the fields are. + itemBuilder: (context) { + final mine = member.id == ledger.me?.id; - // Your own row, or a placeholder's. - final editable = member.isPlaceholder || mine; + // Your own row, or a placeholder's. + final editable = + member.isPlaceholder || mine; - // Removing somebody also cuts off their access to the - // group, so it is offered only once nothing is owed - // either way. - final removable = - !mine && ledger.isSettledUp(member.id); + // Removing somebody also cuts off their access to the + // group, so it is offered only once nothing is owed + // either way. + final removable = + !mine && + ledger.isSettledUp(member.id); - return [ - if (member.isPlaceholder) - const PopupMenuItem( - value: 'invite', - child: Text('Send invite link'), - ), - if (member.isPlaceholder && editable) ...[ - const PopupMenuItem( - value: 'rename', - child: Text('Rename'), - ), - PopupMenuItem( - value: 'upi', - child: Text( - member.upiVpa == null - ? 'Add UPI ID' - : 'Change UPI ID', - ), - ), - ], - if (mine) - const PopupMenuItem( - value: 'me', - child: Text('Edit your name and UPI ID'), - ), - if (removable) - const PopupMenuItem( - value: 'remove', - child: Text('Remove from group'), - ) - // Said rather than silently withheld: an absent - // menu item reads as a bug, and the reason here is - // something the group can act on. - else if (!mine) - PopupMenuItem( - enabled: false, - child: Text( - '${ledger.nameOfMember(member)} is not ' - 'settled up', - ), - ), - ]; - }, - ), + return [ + if (member.isPlaceholder) + const PopupMenuItem( + value: 'invite', + child: Text('Send invite link'), + ), + if (member.isPlaceholder && + editable) ...[ + const PopupMenuItem( + value: 'rename', + child: Text('Rename'), + ), + PopupMenuItem( + value: 'upi', + child: Text( + member.upiVpa == null + ? 'Add UPI ID' + : 'Change UPI ID', + ), + ), + ], + if (mine) + const PopupMenuItem( + value: 'me', + child: Text( + 'Edit your name and UPI ID', + ), + ), + if (removable) + const PopupMenuItem( + value: 'remove', + child: Text('Remove from group'), + ) + // Said rather than silently withheld: an absent + // menu item reads as a bug, and the reason here is + // something the group can act on. + else if (!mine) + PopupMenuItem( + enabled: false, + child: Text( + '${ledger.nameOfMember(member)} is not ' + 'settled up', + ), + ), + ]; + }, + ), ), ], ), @@ -179,9 +194,13 @@ class MembersScreen extends ConsumerWidget { helper: 'They do not need the app. You can invite them later.', ); if (name == null || name.trim().isEmpty) return; - await ref - .read(groupRepositoryProvider) - .addMember(groupId, displayName: name); + if (!context.mounted) return; + await saveWithFeedback( + context, + () async => ref + .read(groupRepositoryProvider) + .addMember(groupId, displayName: name), + ); } /// Records a UPI handle for a member of this group. @@ -199,9 +218,13 @@ class MembersScreen extends ConsumerWidget { controller.dispose(); if (saved == null) return; - await ref - .read(groupRepositoryProvider) - .setMemberUpiVpa(member.id, saved.isEmpty ? null : saved); + if (!context.mounted) return; + await saveWithFeedback( + context, + () => ref + .read(groupRepositoryProvider) + .setMemberUpiVpa(member.id, saved.isEmpty ? null : saved), + ); } Future _rename( @@ -216,7 +239,11 @@ class MembersScreen extends ConsumerWidget { initial: member.displayName, ); if (name == null || name.trim().isEmpty) return; - await ref.read(groupRepositoryProvider).renameMember(member.id, name); + if (!context.mounted) return; + await saveWithFeedback( + context, + () => ref.read(groupRepositoryProvider).renameMember(member.id, name), + ); } Future _remove( @@ -251,8 +278,11 @@ class MembersScreen extends ConsumerWidget { ), ); - if (confirmed ?? false) { - await ref.read(groupRepositoryProvider).removeMember(member.id); + if ((confirmed ?? false) && context.mounted) { + await saveWithFeedback( + context, + () => ref.read(groupRepositoryProvider).removeMember(member.id), + ); } } } diff --git a/lib/presentation/screens/settle_up_screen.dart b/lib/presentation/screens/settle_up_screen.dart index 53359a4..a671bd8 100644 --- a/lib/presentation/screens/settle_up_screen.dart +++ b/lib/presentation/screens/settle_up_screen.dart @@ -22,6 +22,8 @@ import '../widgets/entry_history.dart'; import '../widgets/page_body.dart'; import '../widgets/shared_element.dart'; +import '../widgets/group_access.dart'; + /// Records or edits a payment between two members. /// /// UPI handoff is available when recording a new payment. Editing changes the @@ -46,6 +48,10 @@ class SettleUpScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final ledger = ref.watch(groupLedgerProvider(groupId)); + if (ledger != null && ledger.me?.isActive != true) { + return const GroupUnavailableScreen(); + } final provider = settlementFormProvider( groupId, fromId: fromMemberId, @@ -71,7 +77,6 @@ class SettleUpScreen extends ConsumerWidget { // merely tidiness. ref.watch(groupSyncProvider(groupId)); - final ledger = ref.watch(groupLedgerProvider(groupId)); final form = ref.watch(provider).value; if (ledger == null || form == null) { return Scaffold(appBar: AppBar(leading: const BackButton())); @@ -152,7 +157,7 @@ class _SettleUp extends ConsumerWidget { final payeeName = payee == null ? '' : ledger.nameOfMember(payee); final (from, to) = (form.fromId, form.toId); final editing = form.editing; - final ready = !form.saving && currency != null; + final ready = ledger.canEdit && !form.saving && currency != null; // Keep a recorded payment's participants selectable even after they left // and their balance reached zero. final members = { @@ -170,12 +175,18 @@ class _SettleUp extends ConsumerWidget { leading: BackButton( onPressed: () => goBack(context, '/g/${ledger.group.id}'), ), - title: Text(form.isEditing ? 'Edit payment' : 'Settle up'), + title: Text( + !ledger.canEdit + ? 'Payment' + : form.isEditing + ? 'Edit payment' + : 'Settle up', + ), actions: [ if (form.isEditing) ...[ IconButton( tooltip: 'Delete payment', - onPressed: form.saving ? null : () => _delete(context), + onPressed: ready ? () => _delete(context) : null, icon: const Icon(Icons.delete_outline), ), Padding( @@ -193,122 +204,140 @@ class _SettleUp extends ConsumerWidget { child: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), children: [ - if (editing != null) ...[ - Row( - children: [ - SharedElement( - tag: ('entry-category', editing.id), - child: Icon( - Icons.handshake_outlined, - color: Theme.of(context).colorScheme.onTertiaryContainer, + ExcludeFocus( + excluding: !ledger.canEdit, + child: AbsorbPointer( + absorbing: !ledger.canEdit, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (editing != null) ...[ + Row( + children: [ + SharedElement( + tag: ('entry-category', editing.id), + child: Icon( + Icons.handshake_outlined, + color: Theme.of( + context, + ).colorScheme.onTertiaryContainer, + ), + ), + const SizedBox(width: 12), + Expanded( + child: SharedText( + tag: ('entry-description', editing.id), + text: + '${ledger.nameOf(from ?? '')} paid ' + '${ledger.nameOf(to ?? '')}', + style: Theme.of(context).textTheme.titleLarge! + .copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface, + ), + maxLines: 2, + ), + ), + ], + ), + const SizedBox(height: 24), + ], + AmountCard( + heroTag: editing != null + ? ('entry-amount', editing.id) + : transferIdentity == null + ? null + : ('settlement-amount', transferIdentity!), + currency: currency, + currencyCode: form.currencyCode, + amount: form.amount, + label: 'Amount', + error: form.error, + onAmountChanged: controller.setAmount, + onCurrencyChanged: controller.chooseCurrency, ), - ), - const SizedBox(width: 12), - Expanded( - child: SharedText( - tag: ('entry-description', editing.id), - text: - '${ledger.nameOf(from ?? '')} paid ' - '${ledger.nameOf(to ?? '')}', - style: Theme.of(context).textTheme.titleLarge!.copyWith( - color: Theme.of(context).colorScheme.onSurface, + const SizedBox(height: 24), + _MemberDropdown( + heroTag: transferIdentity == null + ? null + : ('settlement-from', transferIdentity!), + label: form.isEditing ? 'Paid by' : 'Who is paying', + members: members, + nameOf: ledger.nameOfMember, + meId: ledger.me?.id, + value: from, + onChanged: controller.choosePayer, + ), + const SizedBox(height: 16), + _MemberDropdown( + heroTag: transferIdentity == null + ? null + : ('settlement-to', transferIdentity!), + label: form.isEditing ? 'Paid to' : 'Who is being paid', + members: members, + nameOf: ledger.nameOfMember, + meId: ledger.me?.id, + value: to, + excludeId: from, + onChanged: controller.choosePayee, + ), + const SizedBox(height: 16), + if (!form.isEditing && + from != null && + to != null && + currency != null) ...[ + const SizedBox(height: 8), + _OutstandingHint( + ledger: ledger, + from: from, + to: to, + currency: currency, + ), + ], + const SizedBox(height: 8), + _PaymentDetails(form: form, controller: controller), + const SizedBox(height: 24), + if (!form.isEditing && + currency != null && + currency.code == 'INR') ...[ + _UpiSection( + payeeName: payeeName, + // A new payee, or their handle arriving with their profile, + // is a new field: what it shows is never left over from + // somebody else. + fieldKey: ValueKey((to, form.knownHandleIn(ledger))), + handle: form.handleIn(ledger), + onHandleChanged: to == null + ? null + : controller.setHandle, + uri: buildUpiPaymentUri( + payeeVpa: form.handleIn(ledger), + payeeName: payeeName, + amountMinor: currency.parseToMinor(form.amount) ?? 0, + currency: currency, + ), + onPaid: () => _record(context, ref), ), - maxLines: 2, + const SizedBox(height: 24), + ], + if (!form.isEditing) + FilledButton.icon( + onPressed: form.saving || currency == null + ? null + : () => _record(context, ref), + icon: const Icon(Icons.check), + label: const Text('Record this payment'), + ), + const SizedBox(height: 12), + Text( + 'OpenSplit does not move or check money. Recording a payment ' + 'here only updates the balances in this app.', + style: Theme.of(context).textTheme.bodySmall, ), - ), - ], - ), - const SizedBox(height: 24), - ], - AmountCard( - heroTag: editing != null - ? ('entry-amount', editing.id) - : transferIdentity == null - ? null - : ('settlement-amount', transferIdentity!), - currency: currency, - currencyCode: form.currencyCode, - amount: form.amount, - label: 'Amount', - error: form.error, - onAmountChanged: controller.setAmount, - onCurrencyChanged: controller.chooseCurrency, - ), - const SizedBox(height: 24), - _MemberDropdown( - heroTag: transferIdentity == null - ? null - : ('settlement-from', transferIdentity!), - label: form.isEditing ? 'Paid by' : 'Who is paying', - members: members, - nameOf: ledger.nameOfMember, - meId: ledger.me?.id, - value: from, - onChanged: controller.choosePayer, - ), - const SizedBox(height: 16), - _MemberDropdown( - heroTag: transferIdentity == null - ? null - : ('settlement-to', transferIdentity!), - label: form.isEditing ? 'Paid to' : 'Who is being paid', - members: members, - nameOf: ledger.nameOfMember, - meId: ledger.me?.id, - value: to, - excludeId: from, - onChanged: controller.choosePayee, - ), - const SizedBox(height: 16), - if (!form.isEditing && - from != null && - to != null && - currency != null) ...[ - const SizedBox(height: 8), - _OutstandingHint( - ledger: ledger, - from: from, - to: to, - currency: currency, - ), - ], - const SizedBox(height: 8), - _PaymentDetails(form: form, controller: controller), - const SizedBox(height: 24), - if (!form.isEditing && - currency != null && - currency.code == 'INR') ...[ - _UpiSection( - payeeName: payeeName, - // A new payee, or their handle arriving with their profile, - // is a new field: what it shows is never left over from - // somebody else. - fieldKey: ValueKey((to, form.knownHandleIn(ledger))), - handle: form.handleIn(ledger), - onHandleChanged: to == null ? null : controller.setHandle, - uri: buildUpiPaymentUri( - payeeVpa: form.handleIn(ledger), - payeeName: payeeName, - amountMinor: currency.parseToMinor(form.amount) ?? 0, - currency: currency, + ], ), - onPaid: () => _record(context, ref), ), - const SizedBox(height: 24), - ], - if (!form.isEditing) - FilledButton.icon( - onPressed: form.saving || currency == null - ? null - : () => _record(context, ref), - icon: const Icon(Icons.check), - label: const Text('Record this payment'), - ), - const SizedBox(height: 12), - Text( - 'OpenSplit does not move or check money. Recording a payment ' - 'here only updates the balances in this app.', - style: Theme.of(context).textTheme.bodySmall, ), if (editing != null) ...[ const SizedBox(height: 24), diff --git a/lib/presentation/widgets/balances_panel.dart b/lib/presentation/widgets/balances_panel.dart index ab3f774..9c5ba4d 100644 --- a/lib/presentation/widgets/balances_panel.dart +++ b/lib/presentation/widgets/balances_panel.dart @@ -388,10 +388,12 @@ class _TransferTile extends StatelessWidget { Theme.of(context).textTheme.titleMedium!, ).copyWith(color: scheme.onSurface), ), - trailing: FilledButton.tonal( - onPressed: settle, - child: const Text('Settle'), - ), + trailing: !ledger.canEdit + ? null + : FilledButton.tonal( + onPressed: settle, + child: const Text('Settle'), + ), ), ); } diff --git a/lib/presentation/widgets/empty_state.dart b/lib/presentation/widgets/empty_state.dart index 21b3631..dd86389 100644 --- a/lib/presentation/widgets/empty_state.dart +++ b/lib/presentation/widgets/empty_state.dart @@ -27,7 +27,7 @@ class EmptyState extends StatelessWidget { final scheme = theme.colorScheme; return Center( - child: Padding( + child: SingleChildScrollView( padding: const EdgeInsets.all(32), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 320), diff --git a/lib/presentation/widgets/export_button.dart b/lib/presentation/widgets/export_button.dart index 8c38fed..336f356 100644 --- a/lib/presentation/widgets/export_button.dart +++ b/lib/presentation/widgets/export_button.dart @@ -73,8 +73,10 @@ class _ExportButtonState extends ConsumerState { final json = groupToJson( group: ledger.group, - members: ledger.members, - entries: ledger.entries, + members: [...ledger.members, ...ledger.pastMembers], + entries: await ref + .read(entryRepositoryProvider) + .getEntries(widget.groupId, includeDeleted: true), profiles: ledger.profiles, nameOf: ledger.nameOfMember, activity: activity, @@ -108,7 +110,7 @@ class _ExportButtonState extends ConsumerState { ? (ledger?.entries ?? const []) : entries, memberNames: { - for (final member in ledger?.members ?? const []) + for (final member in [...?ledger?.members, ...?ledger?.pastMembers]) // The resolved name, so somebody who has claimed an account appears // in the file under the name everybody actually calls them. member.id: ledger?.nameOfMember(member) ?? member.displayName, diff --git a/lib/presentation/widgets/group_access.dart b/lib/presentation/widgets/group_access.dart new file mode 100644 index 0000000..9cf80fe --- /dev/null +++ b/lib/presentation/widgets/group_access.dart @@ -0,0 +1,56 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../../application/ledger_providers.dart'; +import '../../application/local_providers.dart'; +import '../../application/sync_providers.dart'; +import '../../data/local/database.dart'; +import 'sync_status_notice.dart'; + +/// Keeps every group route behind the account's current membership. +class GroupAccess extends ConsumerWidget { + const GroupAccess({super.key, required this.groupId, required this.child}); + + final String groupId; + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.watch(groupSyncProvider(groupId)); + final group = ref.watch(groupProvider(groupId)); + final members = ref.watch(membersProvider(groupId)); + final account = ref.watch(currentAccountIdProvider); + if (!group.hasValue || !members.hasValue) { + return Scaffold( + appBar: AppBar(leading: const BackButton()), + body: const SavedDataLoading(label: 'Loading saved group…'), + ); + } + if (group.value == null || + (account != null && + !members.value!.any((m) => m.profileId == account && m.isActive))) { + return const GroupUnavailableScreen(); + } + return child; + } +} + +/// No financial data is shown after membership ends, even from cached rows. +class GroupUnavailableScreen extends StatelessWidget { + const GroupUnavailableScreen({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + leading: BackButton(onPressed: () => context.go('/')), + title: const Text('Group unavailable'), + ), + body: const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text('This group is no longer available to your account.'), + ), + ), + ); +} diff --git a/lib/presentation/widgets/sync_refresh_button.dart b/lib/presentation/widgets/sync_refresh_button.dart index cc1ffd7..7d50969 100644 --- a/lib/presentation/widgets/sync_refresh_button.dart +++ b/lib/presentation/widgets/sync_refresh_button.dart @@ -3,6 +3,7 @@ import 'package:flutter/widget_previews.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../application/sync_providers.dart'; +import '../../application/backend_providers.dart'; /// Refreshes through the same coordinator as pull-to-refresh and automatic /// sync. @@ -19,7 +20,9 @@ class SyncRefreshButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final status = ref.watch(syncControllerProvider); - if (!status.enabled) return const SizedBox.shrink(); + if (!status.enabled || ref.watch(requiresAppUpdateProvider)) { + return const SizedBox.shrink(); + } return _RefreshIconButton( isSyncing: status.isSyncing, onRefresh: () async { diff --git a/lib/presentation/widgets/sync_status_notice.dart b/lib/presentation/widgets/sync_status_notice.dart index ad9989e..ccf58e4 100644 --- a/lib/presentation/widgets/sync_status_notice.dart +++ b/lib/presentation/widgets/sync_status_notice.dart @@ -3,6 +3,7 @@ import 'package:flutter/widget_previews.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../application/sync_providers.dart'; +import '../../application/backend_providers.dart'; import '../launch_hold.dart'; import 'inline_notice.dart'; @@ -32,6 +33,14 @@ class InitialSyncGate extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + if (ref.watch(requiresAppUpdateProvider)) { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text('Update OpenSplit to sync your groups.'), + ), + ); + } final status = ref.watch(syncControllerProvider); if (!status.enabled) return child; if (status.isSyncing || @@ -91,6 +100,7 @@ class SyncStatusBanner extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + if (ref.watch(requiresAppUpdateProvider)) return const SizedBox.shrink(); final status = ref.watch(syncControllerProvider); final refreshFailed = status.error != null; final changesWaiting = status.lastReport?.nextPushAt != null; diff --git a/lib/presentation/widgets/unsynced_changes_banner.dart b/lib/presentation/widgets/unsynced_changes_banner.dart index be89dd9..31c7a79 100644 --- a/lib/presentation/widgets/unsynced_changes_banner.dart +++ b/lib/presentation/widgets/unsynced_changes_banner.dart @@ -2,6 +2,7 @@ import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../application/local_providers.dart'; +import '../../application/backend_providers.dart'; import '../../application/sync_providers.dart'; import '../../data/sync/outbox_queue.dart'; import 'inline_notice.dart'; @@ -28,27 +29,30 @@ class UnsyncedChangesBanner extends ConsumerWidget { title: count == 1 ? 'One change could not be saved' : '$count changes could not be saved', - message: count == 1 - ? '“${failures.single.label}” is on this device only. Nobody ' - 'else in the group can see it, and it is not counted ' - 'in their balances.' - : 'They are on this device only. Nobody else in the group ' - 'can see them, and they are not counted in their ' - 'balances.', + message: failures.every((failure) => !failure.canRetry) + ? 'These changes could not be saved. ' + 'Review the details, then discard the local edits before trying a new change.' + : '${count == 1 ? '“${failures.single.label}” has edits' : 'These edits are'} ' + 'on this device only. Review the details before retrying or discarding them.', actions: [ TextButton( onPressed: () => _showDetails(context, failures), child: const Text('Details'), ), TextButton( - onPressed: () => _confirmDiscard(context, ref, failures), + onPressed: ref.watch(requiresAppUpdateProvider) + ? null + : () => _confirmDiscard(context, ref, failures), child: const Text('Discard'), ), - FilledButton( - onPressed: () => - ref.read(syncControllerProvider.notifier).retryFailed(), - child: const Text('Try again'), - ), + if (failures.any((failure) => failure.canRetry)) + FilledButton( + onPressed: ref.watch(requiresAppUpdateProvider) + ? null + : () => + ref.read(syncControllerProvider.notifier).retryFailed(), + child: const Text('Try again'), + ), ], ), ); diff --git a/lib/presentation/widgets/update_required_notice.dart b/lib/presentation/widgets/update_required_notice.dart new file mode 100644 index 0000000..427c4d8 --- /dev/null +++ b/lib/presentation/widgets/update_required_notice.dart @@ -0,0 +1,116 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../../application/backend_providers.dart'; +import '../../application/preferences_providers.dart'; +import '../../data/web/release_updates.dart'; + +/// Keeps navigation and saved data available while an incompatible app updates. +class UpdateRequiredNotice extends ConsumerStatefulWidget { + const UpdateRequiredNotice({super.key, required this.child}); + + final Widget child; + + @override + ConsumerState createState() => + _UpdateRequiredNoticeState(); +} + +class _UpdateRequiredNoticeState extends ConsumerState { + bool _updating = false; + String? _error; + + Future _update() async { + setState(() { + _updating = true; + _error = null; + }); + try { + if (kIsWeb) { + await updateAndRestart(); + } else { + await ref.read(appUpdateServiceProvider).updateRequired(); + } + } catch (_) { + if (mounted) { + setState( + () => _error = + 'Could not open the update. Try again when the new release is available.', + ); + } + } finally { + if (mounted) setState(() => _updating = false); + } + } + + @override + Widget build(BuildContext context) { + if (!ref.watch(requiresAppUpdateProvider)) return widget.child; + final colors = Theme.of(context).colorScheme; + return Column( + children: [ + Material( + color: colors.secondaryContainer, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.all(12), + child: Wrap( + spacing: 12, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Semantics( + liveRegion: true, + child: Text( + _error ?? + 'Update required. Saved data is read-only. Pending changes are kept.', + style: TextStyle(color: colors.onSecondaryContainer), + ), + ), + FilledButton( + onPressed: _updating ? null : _update, + child: Text( + _updating ? 'Opening update…' : 'Update OpenSplit', + ), + ), + ], + ), + ), + ), + ), + Expanded( + child: MediaQuery.removePadding( + context: context, + removeTop: true, + child: widget.child, + ), + ), + ], + ); + } +} + +/// A mutation-only route has no work to offer until this app is updated. +class RequireCurrentApp extends ConsumerWidget { + const RequireCurrentApp({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) => + ref.watch(requiresAppUpdateProvider) + ? Scaffold( + appBar: AppBar(leading: const BackButton()), + body: const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'Update OpenSplit to make changes. Your saved data is still available.', + ), + ), + ), + ) + : child; +} diff --git a/packages/opensplit_api/lib/src/api.dart b/packages/opensplit_api/lib/src/api.dart index 722017b..55e5141 100644 --- a/packages/opensplit_api/lib/src/api.dart +++ b/packages/opensplit_api/lib/src/api.dart @@ -18,7 +18,7 @@ import 'package:opensplit_api/src/api/reference_api.dart'; import 'package:opensplit_api/src/api/sync_api.dart'; class OpensplitApi { - static const String basePath = r'http://localhost'; + static const String basePath = r'/api/v1'; final Dio dio; OpensplitApi({ diff --git a/packages/opensplit_api/lib/src/api/account_api.dart b/packages/opensplit_api/lib/src/api/account_api.dart index d281db5..7f47b9a 100644 --- a/packages/opensplit_api/lib/src/api/account_api.dart +++ b/packages/opensplit_api/lib/src/api/account_api.dart @@ -38,7 +38,7 @@ class AccountApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/account'; + final _path = r'/account'; final _options = Options( method: r'DELETE', headers: {...?headers}, diff --git a/packages/opensplit_api/lib/src/api/devices_api.dart b/packages/opensplit_api/lib/src/api/devices_api.dart index af897cc..ffa1982 100644 --- a/packages/opensplit_api/lib/src/api/devices_api.dart +++ b/packages/opensplit_api/lib/src/api/devices_api.dart @@ -41,7 +41,7 @@ class DevicesApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/devices/{token}'.replaceAll( + final _path = r'/devices/{token}'.replaceAll( '{' r'token' '}', @@ -129,7 +129,7 @@ class DevicesApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/devices'; + final _path = r'/devices'; final _options = Options( method: r'PUT', headers: {...?headers}, diff --git a/packages/opensplit_api/lib/src/api/entries_api.dart b/packages/opensplit_api/lib/src/api/entries_api.dart index a9c5338..0aec97b 100644 --- a/packages/opensplit_api/lib/src/api/entries_api.dart +++ b/packages/opensplit_api/lib/src/api/entries_api.dart @@ -45,7 +45,7 @@ class EntriesApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/groups/{groupId}/entries/{entryId}' + final _path = r'/groups/{groupId}/entries/{entryId}' .replaceAll( '{' r'groupId' diff --git a/packages/opensplit_api/lib/src/api/groups_api.dart b/packages/opensplit_api/lib/src/api/groups_api.dart index 051195c..0524a39 100644 --- a/packages/opensplit_api/lib/src/api/groups_api.dart +++ b/packages/opensplit_api/lib/src/api/groups_api.dart @@ -20,7 +20,7 @@ class GroupsApi { const GroupsApi(this._dio); - /// Make a group and its creator's place in it, or rename, archive or change a setting + /// Make a group and its creator's place in it, or change shared settings /// Idempotent, so a retry whose response was lost returns the same group. Editing needs membership; creating does not. /// /// Parameters: @@ -45,7 +45,7 @@ class GroupsApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/groups/{groupId}'.replaceAll( + final _path = r'/groups/{groupId}'.replaceAll( '{' r'groupId' '}', @@ -122,7 +122,7 @@ class GroupsApi { } /// Add somebody who has never opened the app, or change a name, a payment handle, or whether somebody is still here - /// Your own row and any placeholder are editable; another account holder's are not. Leaving is always yours to do; removing somebody else requires them to be settled in every currency. + /// Your own row and any placeholder are editable; another account holder's are not. Leaving or removing somebody requires them to be settled in every currency; leaving ends access. The last account holder can leave only when the entire group is settled, which permanently deletes the group. /// /// Parameters: /// * [groupId] @@ -148,7 +148,7 @@ class GroupsApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/groups/{groupId}/members/{memberId}' + final _path = r'/groups/{groupId}/members/{memberId}' .replaceAll( '{' r'groupId' diff --git a/packages/opensplit_api/lib/src/api/identity_api.dart b/packages/opensplit_api/lib/src/api/identity_api.dart index 64f2fba..0468cb7 100644 --- a/packages/opensplit_api/lib/src/api/identity_api.dart +++ b/packages/opensplit_api/lib/src/api/identity_api.dart @@ -47,7 +47,7 @@ class IdentityApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/identity/session'; + final _path = r'/identity/session'; final _options = Options( method: r'GET', headers: {...?headers}, @@ -126,7 +126,7 @@ class IdentityApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/identity/google'; + final _path = r'/identity/google'; final _options = Options( method: r'POST', headers: {...?headers}, @@ -224,7 +224,7 @@ class IdentityApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/identity/reauth/verify'; + final _path = r'/identity/reauth/verify'; final _options = Options( method: r'POST', headers: {...?headers}, @@ -316,7 +316,7 @@ class IdentityApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/identity/guest'; + final _path = r'/identity/guest'; final _options = Options( method: r'POST', headers: {...?headers}, @@ -397,7 +397,7 @@ class IdentityApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/identity/sign-out'; + final _path = r'/identity/sign-out'; final _options = Options( method: r'POST', headers: {...?headers}, @@ -450,7 +450,7 @@ class IdentityApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/identity/email'; + final _path = r'/identity/email'; final _options = Options( method: r'POST', headers: {...?headers}, @@ -548,7 +548,7 @@ class IdentityApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/identity/google/redirect'; + final _path = r'/identity/google/redirect'; final _options = Options( method: r'POST', headers: {...?headers}, @@ -644,7 +644,7 @@ class IdentityApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/identity/reauth'; + final _path = r'/identity/reauth'; final _options = Options( method: r'POST', headers: {...?headers}, @@ -727,7 +727,7 @@ class IdentityApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/identity/email/verify'; + final _path = r'/identity/email/verify'; final _options = Options( method: r'POST', headers: {...?headers}, diff --git a/packages/opensplit_api/lib/src/api/invites_api.dart b/packages/opensplit_api/lib/src/api/invites_api.dart index 895df80..737a649 100644 --- a/packages/opensplit_api/lib/src/api/invites_api.dart +++ b/packages/opensplit_api/lib/src/api/invites_api.dart @@ -47,7 +47,7 @@ class InvitesApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/groups/{groupId}/link'.replaceAll( + final _path = r'/groups/{groupId}/link'.replaceAll( '{' r'groupId' '}', @@ -137,7 +137,7 @@ class InvitesApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/groups/{groupId}/members/{memberId}/invite' + final _path = r'/groups/{groupId}/members/{memberId}/invite' .replaceAll( '{' r'groupId' @@ -228,7 +228,7 @@ class InvitesApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/groups/{groupId}/link'.replaceAll( + final _path = r'/groups/{groupId}/link'.replaceAll( '{' r'groupId' '}', @@ -318,7 +318,7 @@ class InvitesApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/links/{token}/join'.replaceAll( + final _path = r'/links/{token}/join'.replaceAll( '{' r'token' '}', @@ -417,7 +417,7 @@ class InvitesApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/links/{token}/placeholders'.replaceAll( + final _path = r'/links/{token}/placeholders'.replaceAll( '{' r'token' '}', @@ -505,7 +505,7 @@ class InvitesApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/links/{token}'.replaceAll( + final _path = r'/links/{token}'.replaceAll( '{' r'token' '}', @@ -593,7 +593,7 @@ class InvitesApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/groups/{groupId}/link'.replaceAll( + final _path = r'/groups/{groupId}/link'.replaceAll( '{' r'groupId' '}', diff --git a/packages/opensplit_api/lib/src/api/meta_api.dart b/packages/opensplit_api/lib/src/api/meta_api.dart index 21b7329..9cddb2f 100644 --- a/packages/opensplit_api/lib/src/api/meta_api.dart +++ b/packages/opensplit_api/lib/src/api/meta_api.dart @@ -37,7 +37,7 @@ class MetaApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/health'; + final _path = r'/health'; final _options = Options( method: r'GET', headers: {...?headers}, diff --git a/packages/opensplit_api/lib/src/api/reference_api.dart b/packages/opensplit_api/lib/src/api/reference_api.dart index 015c4fa..6647309 100644 --- a/packages/opensplit_api/lib/src/api/reference_api.dart +++ b/packages/opensplit_api/lib/src/api/reference_api.dart @@ -43,7 +43,7 @@ class ReferenceApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/fx'; + final _path = r'/fx'; final _options = Options( method: r'GET', headers: {...?headers}, @@ -114,7 +114,7 @@ class ReferenceApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/reference'; + final _path = r'/reference'; final _options = Options( method: r'GET', headers: { @@ -189,7 +189,7 @@ class ReferenceApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/fx/backfill'; + final _path = r'/fx/backfill'; final _options = Options( method: r'POST', headers: {...?headers}, diff --git a/packages/opensplit_api/lib/src/api/sync_api.dart b/packages/opensplit_api/lib/src/api/sync_api.dart index 02dbc27..00d5816 100644 --- a/packages/opensplit_api/lib/src/api/sync_api.dart +++ b/packages/opensplit_api/lib/src/api/sync_api.dart @@ -46,7 +46,7 @@ class SyncApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/profiles'; + final _path = r'/profiles'; final _options = Options( method: r'GET', headers: {...?headers}, @@ -135,7 +135,7 @@ class SyncApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/sync'; + final _path = r'/sync'; final _options = Options( method: r'POST', headers: {...?headers}, @@ -229,7 +229,7 @@ class SyncApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/profile'; + final _path = r'/profile'; final _options = Options( method: r'PUT', headers: {...?headers}, diff --git a/packages/opensplit_api/lib/src/model/error_code.dart b/packages/opensplit_api/lib/src/model/error_code.dart index d5a8dfc..dfab6b6 100644 --- a/packages/opensplit_api/lib/src/model/error_code.dart +++ b/packages/opensplit_api/lib/src/model/error_code.dart @@ -40,6 +40,8 @@ enum ErrorCode { noSession(r'no_session'), @JsonValue(r'not_found') notFound(r'not_found'), + @JsonValue(r'update_required') + updateRequired(r'update_required'), @JsonValue(r'internal') internal(r'internal'), @JsonValue(r'identity_already_in_use') diff --git a/packages/opensplit_api/lib/src/model/error_error.g.dart b/packages/opensplit_api/lib/src/model/error_error.g.dart index 6bef284..869b5f6 100644 --- a/packages/opensplit_api/lib/src/model/error_error.g.dart +++ b/packages/opensplit_api/lib/src/model/error_error.g.dart @@ -56,6 +56,7 @@ const _$ErrorCodeEnumMap = { ErrorCode.malformed: 'malformed', ErrorCode.noSession: 'no_session', ErrorCode.notFound: 'not_found', + ErrorCode.updateRequired: 'update_required', ErrorCode.internal: 'internal', ErrorCode.identityAlreadyInUse: 'identity_already_in_use', ErrorCode.authFailed: 'auth_failed', diff --git a/packages/opensplit_api/lib/src/model/event.g.dart b/packages/opensplit_api/lib/src/model/event.g.dart index e4cf3b4..2ab94ac 100644 --- a/packages/opensplit_api/lib/src/model/event.g.dart +++ b/packages/opensplit_api/lib/src/model/event.g.dart @@ -88,8 +88,6 @@ const _$EventKindEnumMap = { EventKind.memberLeft: 'member_left', EventKind.memberRenamed: 'member_renamed', EventKind.groupRenamed: 'group_renamed', - EventKind.groupArchived: 'group_archived', - EventKind.groupRestored: 'group_restored', EventKind.linkCreated: 'link_created', EventKind.linkRevoked: 'link_revoked', EventKind.unknownDefaultOpenApi: 'unknown_default_open_api', diff --git a/packages/opensplit_api/lib/src/model/event_kind.dart b/packages/opensplit_api/lib/src/model/event_kind.dart index 8c37e55..5d4eb7c 100644 --- a/packages/opensplit_api/lib/src/model/event_kind.dart +++ b/packages/opensplit_api/lib/src/model/event_kind.dart @@ -18,10 +18,6 @@ enum EventKind { memberRenamed(r'member_renamed'), @JsonValue(r'group_renamed') groupRenamed(r'group_renamed'), - @JsonValue(r'group_archived') - groupArchived(r'group_archived'), - @JsonValue(r'group_restored') - groupRestored(r'group_restored'), @JsonValue(r'link_created') linkCreated(r'link_created'), @JsonValue(r'link_revoked') diff --git a/packages/opensplit_api/lib/src/model/group.dart b/packages/opensplit_api/lib/src/model/group.dart index f2e2903..81e512e 100644 --- a/packages/opensplit_api/lib/src/model/group.dart +++ b/packages/opensplit_api/lib/src/model/group.dart @@ -50,8 +50,6 @@ class Group { required this.lastActivityAt, - required this.archivedAt, - required this.updatedAt, required this.seq, @@ -122,9 +120,6 @@ class Group { @JsonKey(name: r'lastActivityAt', required: true, includeIfNull: false) final DateTime lastActivityAt; - @JsonKey(name: r'archivedAt', required: true, includeIfNull: true) - final DateTime? archivedAt; - @JsonKey(name: r'updatedAt', required: true, includeIfNull: false) final DateTime updatedAt; @@ -151,7 +146,6 @@ class Group { other.createdBy == createdBy && other.createdAt == createdAt && other.lastActivityAt == lastActivityAt && - other.archivedAt == archivedAt && other.updatedAt == updatedAt && other.seq == seq; @@ -172,7 +166,6 @@ class Group { createdBy.hashCode + createdAt.hashCode + lastActivityAt.hashCode + - (archivedAt == null ? 0 : archivedAt.hashCode) + updatedAt.hashCode + seq.hashCode; diff --git a/packages/opensplit_api/lib/src/model/group.g.dart b/packages/opensplit_api/lib/src/model/group.g.dart index a70ae01..849acf4 100644 --- a/packages/opensplit_api/lib/src/model/group.g.dart +++ b/packages/opensplit_api/lib/src/model/group.g.dart @@ -27,7 +27,6 @@ Group _$GroupFromJson( 'createdBy', 'createdAt', 'lastActivityAt', - 'archivedAt', 'updatedAt', 'seq', ], @@ -79,10 +78,6 @@ Group _$GroupFromJson( 'lastActivityAt', (v) => DateTime.parse(v as String), ), - archivedAt: $checkedConvert( - 'archivedAt', - (v) => v == null ? null : DateTime.parse(v as String), - ), updatedAt: $checkedConvert('updatedAt', (v) => DateTime.parse(v as String)), seq: $checkedConvert('seq', (v) => (v as num).toInt()), ); @@ -105,7 +100,6 @@ Map _$GroupToJson(Group instance) => { 'createdBy': instance.createdBy, 'createdAt': instance.createdAt.toIso8601String(), 'lastActivityAt': instance.lastActivityAt.toIso8601String(), - 'archivedAt': instance.archivedAt?.toIso8601String(), 'updatedAt': instance.updatedAt.toIso8601String(), 'seq': instance.seq, }; diff --git a/packages/opensplit_api/lib/src/model/group_input.dart b/packages/opensplit_api/lib/src/model/group_input.dart index ea0c293..3030269 100644 --- a/packages/opensplit_api/lib/src/model/group_input.dart +++ b/packages/opensplit_api/lib/src/model/group_input.dart @@ -28,8 +28,6 @@ class GroupInput { required this.simplifyDebts, - required this.archivedAt, - required this.avatarKind, required this.avatarColor, @@ -47,6 +45,8 @@ class GroupInput { required this.creatorId, required this.creatorName, + + required this.baseSeq, }); @JsonKey(name: r'name', required: true, includeIfNull: false) @@ -61,9 +61,6 @@ class GroupInput { @JsonKey(name: r'simplifyDebts', required: true, includeIfNull: false) final bool simplifyDebts; - @JsonKey(name: r'archivedAt', required: true, includeIfNull: true) - final DateTime? archivedAt; - @JsonKey( name: r'avatarKind', required: true, @@ -111,6 +108,10 @@ class GroupInput { @JsonKey(name: r'creatorName', required: true, includeIfNull: false) final String creatorName; + // minimum: 0 + @JsonKey(name: r'baseSeq', required: true, includeIfNull: true) + final int? baseSeq; + @override bool operator ==(Object other) => identical(this, other) || @@ -119,7 +120,6 @@ class GroupInput { other.defaultCurrency == defaultCurrency && other.isDirect == isDirect && other.simplifyDebts == simplifyDebts && - other.archivedAt == archivedAt && other.avatarKind == avatarKind && other.avatarColor == avatarColor && other.avatarEmoji == avatarEmoji && @@ -128,7 +128,8 @@ class GroupInput { other.coverKind == coverKind && other.coverPhoto == coverPhoto && other.creatorId == creatorId && - other.creatorName == creatorName; + other.creatorName == creatorName && + other.baseSeq == baseSeq; @override int get hashCode => @@ -136,7 +137,6 @@ class GroupInput { defaultCurrency.hashCode + isDirect.hashCode + simplifyDebts.hashCode + - (archivedAt == null ? 0 : archivedAt.hashCode) + avatarKind.hashCode + (avatarColor == null ? 0 : avatarColor.hashCode) + (avatarEmoji == null ? 0 : avatarEmoji.hashCode) + @@ -145,7 +145,8 @@ class GroupInput { coverKind.hashCode + (coverPhoto == null ? 0 : coverPhoto.hashCode) + creatorId.hashCode + - creatorName.hashCode; + creatorName.hashCode + + (baseSeq == null ? 0 : baseSeq.hashCode); factory GroupInput.fromJson(Map json) => _$GroupInputFromJson(json); diff --git a/packages/opensplit_api/lib/src/model/group_input.g.dart b/packages/opensplit_api/lib/src/model/group_input.g.dart index 00eeafd..05c99c2 100644 --- a/packages/opensplit_api/lib/src/model/group_input.g.dart +++ b/packages/opensplit_api/lib/src/model/group_input.g.dart @@ -15,7 +15,6 @@ GroupInput _$GroupInputFromJson(Map json) => 'defaultCurrency', 'isDirect', 'simplifyDebts', - 'archivedAt', 'avatarKind', 'avatarColor', 'avatarEmoji', @@ -25,6 +24,7 @@ GroupInput _$GroupInputFromJson(Map json) => 'coverPhoto', 'creatorId', 'creatorName', + 'baseSeq', ], ); final val = GroupInput( @@ -32,10 +32,6 @@ GroupInput _$GroupInputFromJson(Map json) => defaultCurrency: $checkedConvert('defaultCurrency', (v) => v as String), isDirect: $checkedConvert('isDirect', (v) => v as bool), simplifyDebts: $checkedConvert('simplifyDebts', (v) => v as bool), - archivedAt: $checkedConvert( - 'archivedAt', - (v) => v == null ? null : DateTime.parse(v as String), - ), avatarKind: $checkedConvert( 'avatarKind', (v) => $enumDecode( @@ -73,6 +69,7 @@ GroupInput _$GroupInputFromJson(Map json) => coverPhoto: $checkedConvert('coverPhoto', (v) => v as String?), creatorId: $checkedConvert('creatorId', (v) => v as String), creatorName: $checkedConvert('creatorName', (v) => v as String), + baseSeq: $checkedConvert('baseSeq', (v) => (v as num?)?.toInt()), ); return val; }); @@ -83,7 +80,6 @@ Map _$GroupInputToJson(GroupInput instance) => 'defaultCurrency': instance.defaultCurrency, 'isDirect': instance.isDirect, 'simplifyDebts': instance.simplifyDebts, - 'archivedAt': instance.archivedAt?.toIso8601String(), 'avatarKind': _$AvatarKindEnumMap[instance.avatarKind]!, 'avatarColor': _$AvatarColorEnumMap[instance.avatarColor], 'avatarEmoji': instance.avatarEmoji, @@ -93,6 +89,7 @@ Map _$GroupInputToJson(GroupInput instance) => 'coverPhoto': instance.coverPhoto, 'creatorId': instance.creatorId, 'creatorName': instance.creatorName, + 'baseSeq': instance.baseSeq, }; const _$AvatarKindEnumMap = { diff --git a/packages/opensplit_api/lib/src/model/group_refusal.g.dart b/packages/opensplit_api/lib/src/model/group_refusal.g.dart index 0d372d6..216d3ae 100644 --- a/packages/opensplit_api/lib/src/model/group_refusal.g.dart +++ b/packages/opensplit_api/lib/src/model/group_refusal.g.dart @@ -49,6 +49,7 @@ const _$ErrorCodeEnumMap = { ErrorCode.malformed: 'malformed', ErrorCode.noSession: 'no_session', ErrorCode.notFound: 'not_found', + ErrorCode.updateRequired: 'update_required', ErrorCode.internal: 'internal', ErrorCode.identityAlreadyInUse: 'identity_already_in_use', ErrorCode.authFailed: 'auth_failed', diff --git a/packages/opensplit_api/lib/src/model/member_input.dart b/packages/opensplit_api/lib/src/model/member_input.dart index 9466f7b..71c0c1d 100644 --- a/packages/opensplit_api/lib/src/model/member_input.dart +++ b/packages/opensplit_api/lib/src/model/member_input.dart @@ -21,6 +21,8 @@ class MemberInput { required this.upiVpa, required this.leftAt, + + required this.baseSeq, }); @JsonKey(name: r'displayName', required: true, includeIfNull: false) @@ -32,19 +34,25 @@ class MemberInput { @JsonKey(name: r'leftAt', required: true, includeIfNull: true) final DateTime? leftAt; + // minimum: 0 + @JsonKey(name: r'baseSeq', required: true, includeIfNull: true) + final int? baseSeq; + @override bool operator ==(Object other) => identical(this, other) || other is MemberInput && other.displayName == displayName && other.upiVpa == upiVpa && - other.leftAt == leftAt; + other.leftAt == leftAt && + other.baseSeq == baseSeq; @override int get hashCode => displayName.hashCode + (upiVpa == null ? 0 : upiVpa.hashCode) + - (leftAt == null ? 0 : leftAt.hashCode); + (leftAt == null ? 0 : leftAt.hashCode) + + (baseSeq == null ? 0 : baseSeq.hashCode); factory MemberInput.fromJson(Map json) => _$MemberInputFromJson(json); diff --git a/packages/opensplit_api/lib/src/model/member_input.g.dart b/packages/opensplit_api/lib/src/model/member_input.g.dart index 47a9b78..46bd1cf 100644 --- a/packages/opensplit_api/lib/src/model/member_input.g.dart +++ b/packages/opensplit_api/lib/src/model/member_input.g.dart @@ -8,7 +8,10 @@ part of 'member_input.dart'; MemberInput _$MemberInputFromJson(Map json) => $checkedCreate('MemberInput', json, ($checkedConvert) { - $checkKeys(json, requiredKeys: const ['displayName', 'upiVpa', 'leftAt']); + $checkKeys( + json, + requiredKeys: const ['displayName', 'upiVpa', 'leftAt', 'baseSeq'], + ); final val = MemberInput( displayName: $checkedConvert('displayName', (v) => v as String), upiVpa: $checkedConvert('upiVpa', (v) => v as String?), @@ -16,6 +19,7 @@ MemberInput _$MemberInputFromJson(Map json) => 'leftAt', (v) => v == null ? null : DateTime.parse(v as String), ), + baseSeq: $checkedConvert('baseSeq', (v) => (v as num?)?.toInt()), ); return val; }); @@ -25,4 +29,5 @@ Map _$MemberInputToJson(MemberInput instance) => 'displayName': instance.displayName, 'upiVpa': instance.upiVpa, 'leftAt': instance.leftAt?.toIso8601String(), + 'baseSeq': instance.baseSeq, }; diff --git a/packages/opensplit_api/lib/src/model/push_data.g.dart b/packages/opensplit_api/lib/src/model/push_data.g.dart index 72b5b80..c256ad9 100644 --- a/packages/opensplit_api/lib/src/model/push_data.g.dart +++ b/packages/opensplit_api/lib/src/model/push_data.g.dart @@ -42,8 +42,6 @@ const _$EventKindEnumMap = { EventKind.memberLeft: 'member_left', EventKind.memberRenamed: 'member_renamed', EventKind.groupRenamed: 'group_renamed', - EventKind.groupArchived: 'group_archived', - EventKind.groupRestored: 'group_restored', EventKind.linkCreated: 'link_created', EventKind.linkRevoked: 'link_revoked', EventKind.unknownDefaultOpenApi: 'unknown_default_open_api', diff --git a/pubspec.yaml b/pubspec.yaml index 33b8114..4f2d311 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,7 +7,7 @@ publish_to: "none" # never by hand (see "Versions" in docs/runbook.md). There is no `+build` # here on purpose. The release workflow numbers every build itself, and a # number written here would only be a second, stale answer. -version: 3.1.0 +version: 4.0.0 environment: sdk: ^3.12.2 diff --git a/server/scripts/write-openapi.ts b/server/scripts/write-openapi.ts index 9d984b3..5d9d415 100644 --- a/server/scripts/write-openapi.ts +++ b/server/scripts/write-openapi.ts @@ -1,10 +1,10 @@ import { writeFile } from "node:fs/promises"; import path from "node:path"; -import { app } from "../src/app"; +import { api } from "../src/app"; import { openApiDocument } from "../src/openapi"; /** Writes the committed contract, which CI diffs and the Dart client is generated from. */ const out = path.join(import.meta.dirname, "..", "..", "docs", "openapi.json"); -await writeFile(out, `${JSON.stringify(app.getOpenAPIDocument(openApiDocument), null, 2)}\n`); +await writeFile(out, `${JSON.stringify(api.getOpenAPIDocument(openApiDocument), null, 2)}\n`); console.log(`wrote ${out}`); diff --git a/server/src/api/ledger.ts b/server/src/api/ledger.ts index 2f83ab3..c2c50b4 100644 --- a/server/src/api/ledger.ts +++ b/server/src/api/ledger.ts @@ -36,7 +36,7 @@ const putGroupRoute = createRoute({ operationId: "putGroup", path: "/groups/{groupId}", tags: ["groups"], - summary: "Make a group and its creator's place in it, or rename, archive or change a setting", + summary: "Make a group and its creator's place in it, or change shared settings", description: "Idempotent, so a retry whose response was lost returns the same group. Editing needs membership; creating does not.", request: { params: GroupPathSchema, body: jsonBody(GroupInputSchema) }, responses: { 200: jsonResponse(GroupSchema, "The group"), ...refusals }, @@ -49,7 +49,7 @@ const putMemberRoute = createRoute({ path: "/groups/{groupId}/members/{memberId}", tags: ["groups"], summary: "Add somebody who has never opened the app, or change a name, a payment handle, or whether somebody is still here", - description: "Your own row and any placeholder are editable; another account holder's are not. Leaving is always yours to do; removing somebody else requires them to be settled in every currency.", + description: "Your own row and any placeholder are editable; another account holder's are not. Leaving or removing somebody requires them to be settled in every currency; leaving ends access. The last account holder can leave only when the entire group is settled, which permanently deletes the group.", request: { params: MemberPathSchema, body: jsonBody(MemberInputSchema) }, responses: { 200: jsonResponse(MemberSchema, "The member"), ...refusals }, }); @@ -109,7 +109,7 @@ export function ledgerRoutes(routes: OpenAPIHono) { }); } -/** The groups an account is in now, from D1's index of them. */ +/** Groups the account actively participates in. */ async function groupsOf(db: DrizzleD1Database, profileId: string): Promise { const rows = await db .select({ groupId: memberships.groupId }) diff --git a/server/src/api/routing.ts b/server/src/api/routing.ts index f85ec33..43b365c 100644 --- a/server/src/api/routing.ts +++ b/server/src/api/routing.ts @@ -3,6 +3,7 @@ import type { Context } from "hono"; import { requireSession, withSession } from "../context"; import { kindOf, type Refusal, type Result, statusFor } from "../do/group/refusal"; +import { currentApiVersion } from "../paths"; import { apiError, errorResponse, IdSchema } from "../schemas/common"; /** What every route module shares: session guards, addressing, and how a refusal becomes a response. */ @@ -56,6 +57,16 @@ export const rateLimited = { 429: errorResponse("Too many attempts. Wait a minut type WorkerEnv = { Bindings: Env }; +/** Only a retired numbered contract asks the client to update. Other missing paths remain 404s. */ +export function unavailableApi(c: Context, currentVersion: number = currentApiVersion) { + const requestedVersion = /^\/api\/v([1-9]\d*)(?:\/|$)/.exec(c.req.path)?.[1]; + if (requestedVersion !== undefined && Number(requestedVersion) < currentVersion) { + c.header("Cache-Control", "no-store"); + return c.json(apiError("update_required", "Update OpenSplit to connect to the server."), 410); + } + return c.json(apiError("not_found", "No such endpoint."), 404); +} + /** The client's address. Cloudflare always sets it; only a test calling the Worker directly omits it. */ export function clientIp(c: Context): string { return c.req.header("cf-connecting-ip") ?? "unknown"; diff --git a/server/src/app.ts b/server/src/app.ts index 8bd161a..5c05096 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -6,9 +6,11 @@ import { accountRoutes } from "./api/account"; import { inviteRoutes } from "./api/invites"; import { ledgerRoutes } from "./api/ledger"; import { referenceRoutes } from "./api/reference"; +import { unavailableApi } from "./api/routing"; import { type AppEnv, services } from "./context"; import { identityRoutes } from "./identity/routes"; import { openApiDocument } from "./openapi"; +import { apiBasePath, authBasePath } from "./paths"; import { apiError, jsonResponse } from "./schemas/common"; import { AccountSchema } from "./schemas/identity"; import { EntrySnapshotSchema, GroupEventPayloadSchema, GroupLinkSchema, GroupSchema, LinkEventPayloadSchema, MemberEventPayloadSchema, PushDataSchema } from "./schemas/ledger"; @@ -31,36 +33,43 @@ const app = new OpenAPIHono({ }, }); +// Emit this router's relative paths with the shared OpenAPI server URL. The +// parent router owns the mount and the operational endpoints, not the contract. +const api = new OpenAPIHono(); + /** * Components that some field holds as nullable, registered before any route * uses them. The generator builds a component from its first use, so one first * met through `.nullable()` would itself be emitted nullable, everywhere. */ for (const schema of [GroupSchema, GroupLinkSchema, AccountSchema, EntrySnapshotSchema, MemberEventPayloadSchema, GroupEventPayloadSchema, LinkEventPayloadSchema]) { - app.openAPIRegistry.register(schema.meta()?.id as string, schema); + api.openAPIRegistry.register(schema.meta()?.id as string, schema); } -app.use("/api/*", services); +// Only the current contract reaches account and ledger services. +app.use(`${apiBasePath}/*`, services); // Better Auth's own handler, only for the redirect Google sends the browser back to. -app.on(["GET", "POST"], ["/api/auth/callback/*", "/api/auth/error"], (c) => c.var.auth.handler(c.req.raw)); +app.on(["GET", "POST"], [`${authBasePath}/callback/*`, `${authBasePath}/error`], (c) => c.var.auth.handler(c.req.raw)); -const api = new OpenAPIHono(); -api.openapi(healthRoute, (c) => c.json({ ok: true as const, service: "opensplit" as const, now: new Date().toISOString() }, 200)); +const health = () => ({ ok: true as const, service: "opensplit" as const, now: new Date().toISOString() }); +api.openapi(healthRoute, (c) => c.json(health(), 200)); +// Deployment probes remain stable across app contract versions. +app.get("/api/health", (c) => c.json(health(), 200)); identityRoutes(api); ledgerRoutes(api); inviteRoutes(api); accountRoutes(api); referenceRoutes(api); -app.route("/api", api); -app.openAPIRegistry.registerComponent("securitySchemes", "bearer", { type: "http", scheme: "bearer", description: "The session token from `set-auth-token`, on Android." }); -app.openAPIRegistry.registerComponent("securitySchemes", "cookie", { type: "apiKey", in: "cookie", name: "better-auth.session_token", description: "The HttpOnly session cookie, on the web." }); +api.openAPIRegistry.registerComponent("securitySchemes", "bearer", { type: "http", scheme: "bearer", description: "The session token from `set-auth-token`, on Android." }); +api.openAPIRegistry.registerComponent("securitySchemes", "cookie", { type: "apiKey", in: "cookie", name: "better-auth.session_token", description: "The HttpOnly session cookie, on the web." }); // Sent over FCM rather than HTTP, so no route references it. -app.openAPIRegistry.register("PushData", PushDataSchema); -app.doc("/api/openapi.json", openApiDocument); +api.openAPIRegistry.register("PushData", PushDataSchema); +app.route(apiBasePath, api); +app.get("/api/openapi.json", (c) => c.json(api.getOpenAPIDocument(openApiDocument))); -app.all("/api/*", (c) => c.json(apiError("not_found", "No such endpoint."), 404)); +app.all("/api/*", (c) => unavailableApi(c)); /** * No asset matched. A path under `/app` is a client deep link such as @@ -91,4 +100,4 @@ app.onError((error, c) => { return c.json(apiError("internal", "Something went wrong.", "transient"), 500); }); -export { app }; +export { api, app }; diff --git a/server/src/auth.ts b/server/src/auth.ts index d9c3716..dd24aa2 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -11,6 +11,7 @@ import { profiles } from "./db/d1/schema"; import { createEmailSender } from "./email/sender"; import { signInCodeMessage } from "./email/sign-in-code"; import { handOverGuest } from "./forget"; +import { authBasePath } from "./paths"; /** * Better Auth on D1. Its tables are generated into `src/auth-schema.ts`; @@ -30,7 +31,7 @@ export function build(env: Env) { database: drizzleAdapter(drizzle(env.DB, { relations: authSchema.authRelations }), { provider: "sqlite", schema: authSchema }), secret: env.BETTER_AUTH_SECRET, baseURL: env.APP_ORIGIN, - basePath: "/api/auth", + basePath: authBasePath, trustedOrigins: [env.APP_ORIGIN], // No passwords: Google, an emailed code, or a guest. diff --git a/server/src/db/group/schema.ts b/server/src/db/group/schema.ts index f8887c8..c2167b8 100644 --- a/server/src/db/group/schema.ts +++ b/server/src/db/group/schema.ts @@ -19,7 +19,7 @@ export const splitKinds = ["equal", "exact", "shares", "percent"] as const; /** Event kinds, grouped by the payload each group carries. */ export const memberEventKinds = ["member_added", "member_joined", "member_left", "member_renamed"] as const; -export const groupEventKinds = ["group_renamed", "group_archived", "group_restored"] as const; +export const groupEventKinds = ["group_renamed"] as const; export const linkEventKinds = ["link_created", "link_revoked"] as const; export const eventKinds = ["entry", ...memberEventKinds, ...groupEventKinds, ...linkEventKinds] as const; @@ -73,7 +73,6 @@ export const meta = sqliteTable( .references(() => members.id), createdAt: text("created_at").notNull(), lastActivityAt: text("last_activity_at").notNull(), - archivedAt: text("archived_at"), updatedAt: text("updated_at").notNull(), seq: integer("seq").notNull(), }, @@ -242,7 +241,7 @@ export const outbox = sqliteTable("outbox", { createdAt: text("created_at").notNull(), }); -export const chores = ["outbox", "archive", "purge"] as const; +export const chores = ["outbox"] as const; export type Chore = (typeof chores)[number]; /** Deadlines owed to the object's single alarm, which is armed at the earliest. */ diff --git a/server/src/do/group/balances.ts b/server/src/do/group/balances.ts index ddbb0b2..01d0d9c 100644 --- a/server/src/do/group/balances.ts +++ b/server/src/do/group/balances.ts @@ -4,8 +4,8 @@ import type { Tx } from "./store"; /** * The one thing the server computes about money, for the two decisions a - * device cannot be trusted with: removing somebody else, and collecting a - * dormant group. Per currency; only non-zero positions, so no rows is settled. + * device cannot be trusted with: removing somebody else, and closing a group when its + * last account holder leaves. Per currency; only non-zero positions, so no rows is settled. */ const positions = sql` select member_id as memberId, currency, sum(delta) as balanceMinor diff --git a/server/src/do/group/changes.ts b/server/src/do/group/changes.ts index 9bc5e40..d1af0a7 100644 --- a/server/src/do/group/changes.ts +++ b/server/src/do/group/changes.ts @@ -23,26 +23,22 @@ export function changesSince(tx: Tx, profileId: string, since: number, limit: nu const meta = findMeta(tx); if (!meta) refuse("no_group", "No such group."); const member = findMemberByProfile(tx, profileId); - if (!member) refuse("not_member", "You are not a member of this group."); - - // Somebody who left reads up to the change that recorded their leaving, and no further. - const ceiling = member.leftAt === null ? Number.MAX_SAFE_INTEGER : member.seq; - + if (!member || member.leftAt !== null) refuse("not_member", "You are not a member of this group."); // One more than the page holds answers `hasMore` without a count. const moved = tx.all<{ seq: number }>(sql` select seq from ( - select seq from ${schema.meta} where seq > ${since} and seq <= ${ceiling} + select seq from ${schema.meta} where seq > ${since} union - select seq from ${schema.members} where seq > ${since} and seq <= ${ceiling} + select seq from ${schema.members} where seq > ${since} union - select seq from ${schema.entries} where seq > ${since} and seq <= ${ceiling} + select seq from ${schema.entries} where seq > ${since} union - select seq from ${schema.events} where seq > ${since} and seq <= ${ceiling} + select seq from ${schema.events} where seq > ${since} ) order by seq limit ${limit + 1}`); - if (moved.length === 0) return { ...empty, groupId: meta.id, seq: since, hasMore: false, purgedAt: null }; + if (moved.length === 0) return { ...empty, group: meta, groupId: meta.id, seq: since, hasMore: false, purgedAt: null }; const hasMore = moved.length > limit; const upTo = moved.slice(0, limit).at(-1)?.seq ?? since; @@ -55,14 +51,8 @@ export function changesSince(tx: Tx, profileId: string, since: number, limit: nu purgedAt: null, // The group and member rows changed since the cursor ride on every page, even ahead of it: // this page's entries and events may name them, and a device's foreign keys need them first. - // Not past the ceiling: somebody who left does not hear what the group is called now. - group: meta.seq > since && meta.seq <= ceiling ? meta : null, - members: tx - .select() - .from(schema.members) - .where(and(gt(schema.members.seq, since), lte(schema.members.seq, ceiling))) - .orderBy(asc(schema.members.seq)) - .all(), + group: meta, + members: tx.select().from(schema.members).where(gt(schema.members.seq, since)).orderBy(asc(schema.members.seq)).all(), entries: readEntries(tx, tx.select().from(schema.entries).where(window(schema.entries.seq)).orderBy(asc(schema.entries.seq)).all()), events: tx.select().from(schema.events).where(window(schema.events.seq)).orderBy(asc(schema.events.seq), asc(schema.events.ordinal)).all(), }; diff --git a/server/src/do/group/index.ts b/server/src/do/group/index.ts index 73de5ed..82b3ae0 100644 --- a/server/src/do/group/index.ts +++ b/server/src/do/group/index.ts @@ -15,8 +15,8 @@ import migrations from "./migrations/migrations"; import { pendingNotices } from "./notices"; import { attempt, type Result } from "./refusal"; import { createGroup, forgetProfile, handOver, putMember, updateGroup } from "./roster"; -import { currentSeq, findMeta, findTombstone, type GroupDb, nowIso, requireActiveMember, requireMeta, type Tx, type WriteContext } from "./store"; -import { backoffOutbox, clearOutbox, nextDue, type OutboxRow, pendingOutbox, runDormancy, touchDormancy, type UpkeepOutcome } from "./upkeep"; +import { currentSeq, findMemberByProfile, findMeta, findTombstone, type GroupDb, nowIso, requireActiveMember, requireMeta, type Tx, type WriteContext } from "./store"; +import { backoffOutbox, clearOutbox, collectUnowned, nextDue, type OutboxRow, pendingOutbox, touchActivity, type UpkeepOutcome } from "./upkeep"; /** * One group's ledger and its authorization boundary. The object runs one @@ -33,7 +33,9 @@ export class Group extends DurableObject { super(ctx, env); this.db = drizzle(ctx.storage, { logger: false }); // Each object migrates itself lazily, on its first open after a deploy. - ctx.blockConcurrencyWhile(async () => migrate(this.db, migrations)); + ctx.blockConcurrencyWhile(async () => { + migrate(this.db, migrations); + }); } async ping(): Promise { @@ -46,11 +48,20 @@ export class Group extends DurableObject { /** Creates the group at this object's id, or edits it. Creating needs no membership; editing does. */ async putGroup(groupId: string, input: GroupInput, profileId: string) { - return this.write(profileId, (tx, now) => (findMeta(tx) === undefined && findTombstone(tx) === undefined ? createGroup(tx, groupId, input, profileId, now) : updateGroup(tx, input, as(tx, profileId, now)))); + return this.write(profileId, (tx, now) => { + if (findMeta(tx) === undefined && findTombstone(tx) === undefined) createGroup(tx, groupId, input, profileId, now); + else updateGroup(tx, input, as(tx, profileId, now)); + return requireMeta(tx); + }); } async putMember(memberId: string, input: MemberInput, profileId: string) { - return this.write(profileId, (tx, now) => putMember(tx, memberId, input, as(tx, profileId, now))); + return this.write(profileId, (tx, now) => { + const self = findMemberByProfile(tx, profileId); + // A lost leave response must still be acknowledgeable after access ended. + if (self?.id === memberId && self.leftAt !== null && input.leftAt !== null && input.displayName === self.displayName && input.upiVpa === self.upiVpa) return self; + return putMember(tx, memberId, input, as(tx, profileId, now)); + }); } async putEntry(entryId: string, input: EntryInput, profileId: string) { @@ -108,15 +119,15 @@ export class Group extends DurableObject { return outcome; } - /** Housekeeping as of an instant, so dormancy is testable without waiting months. */ + /** Collects only groups with no active account. */ async runUpkeep(now: number = Date.now()): Promise { - const outcome = this.db.transaction((tx) => runDormancy(tx, now)); + const outcome = this.db.transaction((tx) => collectUnowned(tx, now)); await this.settle(); return outcome; } override async alarm(): Promise { - this.db.transaction((tx) => runDormancy(tx, Date.now())); + this.db.transaction((tx) => collectUnowned(tx, Date.now())); // Re-armed unconditionally: the alarm that is firing is spent once this returns. await this.settle({ rearm: true }); } @@ -141,7 +152,7 @@ export class Group extends DurableObject { const before = currentSeq(tx); const value = body(tx, now); const after = currentSeq(tx); - if (after > before) touchDormancy(tx, now); + if (after > before) touchActivity(tx, now); return { value, spent: after > before ? after : null }; }), ); @@ -155,15 +166,19 @@ export class Group extends DurableObject { /** Push runs after the commit, inside `waitUntil`, and can never fail the write. */ private announce(seq: number, actorProfileId: string): void { - const { groupId, messages } = this.db.transaction((tx) => ({ groupId: requireMeta(tx).id, messages: pendingNotices(tx, seq) })); - if (messages.length === 0) return; + const { groupId, messages } = this.db.transaction((tx) => { + const groupId = findMeta(tx)?.id; + return { groupId, messages: groupId === undefined ? [] : pendingNotices(tx, seq) }; + }); + if (groupId === undefined || messages.length === 0) return; this.ctx.waitUntil(wakeDevices(this.env, groupId, actorProfileId, messages).catch((error) => console.error("[group] push failed", error))); } private async settle({ rearm = false } = {}): Promise { await this.flush(); const due = this.db.transaction((tx) => nextDue(tx)); - if (due !== null && (rearm || (await this.ctx.storage.getAlarm()) !== due)) await this.ctx.storage.setAlarm(due); + if (due === null) await this.ctx.storage.deleteAlarm(); + else if (rearm || (await this.ctx.storage.getAlarm()) !== due) await this.ctx.storage.setAlarm(due); } /** The flush in progress, if any. Flushes run one after another, never side by side. */ diff --git a/server/src/do/group/ledger.ts b/server/src/do/group/ledger.ts index 8d0e7d1..bae0a29 100644 --- a/server/src/do/group/ledger.ts +++ b/server/src/do/group/ledger.ts @@ -6,7 +6,7 @@ import { type Entry, type EntryInput, editableEntryColumns } from "../../schemas import { positionsByMember } from "./balances"; import { appendSnapshot, byMember, moneyRows } from "./events"; import { refuse } from "./refusal"; -import { type EntryRow, nextSeq, requireMeta, type Tx, type WriteContext } from "./store"; +import { type EntryRow, nextSeq, type Tx, type WriteContext } from "./store"; /** * The expense write path. Every rule is checked here against the finished @@ -50,7 +50,6 @@ function requireEntry(tx: Tx, id: string): Entry { * its shares and whether it counts are one fact. */ export function putEntry(tx: Tx, id: string, input: EntryInput, { now, actor }: WriteContext): Entry { - const meta = requireMeta(tx); assertBalanced(input); assertMembersExist(tx, input); @@ -68,11 +67,6 @@ export function putEntry(tx: Tx, id: string, input: EntryInput, { now, actor }: const seq = nextSeq(tx); - // A live expense brings an archived group back, silently: the expense is the record. - if (meta.archivedAt !== null && input.deletedAt === null) { - tx.update(schema.meta).set({ archivedAt: null, updatedAt: now, seq }).where(eq(schema.meta.id, meta.id)).run(); - } - // A rate keeps its timestamp unless the rate or its source changed. const fxAt = stored && stored.fxRate === input.fxRate && stored.fxSource === input.fxSource ? stored.fxAt : input.fxRate !== null ? now : null; // The device says whether; the server's clock says when, once. diff --git a/server/src/do/group/migrations/20261005061632_gigantic_mother_askani/migration.sql b/server/src/do/group/migrations/20261014133419_previous_gorilla_man/migration.sql similarity index 99% rename from server/src/do/group/migrations/20261005061632_gigantic_mother_askani/migration.sql rename to server/src/do/group/migrations/20261014133419_previous_gorilla_man/migration.sql index cddfc77..700ece8 100644 --- a/server/src/do/group/migrations/20261005061632_gigantic_mother_askani/migration.sql +++ b/server/src/do/group/migrations/20261014133419_previous_gorilla_man/migration.sql @@ -117,7 +117,6 @@ CREATE TABLE `meta` ( `created_by` text NOT NULL, `created_at` text NOT NULL, `last_activity_at` text NOT NULL, - `archived_at` text, `updated_at` text NOT NULL, `seq` integer NOT NULL, CONSTRAINT `fk_meta_created_by_members_id_fk` FOREIGN KEY (`created_by`) REFERENCES `members`(`id`), diff --git a/server/src/do/group/migrations/20261005061632_gigantic_mother_askani/snapshot.json b/server/src/do/group/migrations/20261014133419_previous_gorilla_man/snapshot.json similarity index 99% rename from server/src/do/group/migrations/20261005061632_gigantic_mother_askani/snapshot.json rename to server/src/do/group/migrations/20261014133419_previous_gorilla_man/snapshot.json index d37270f..2fe5f61 100644 --- a/server/src/do/group/migrations/20261005061632_gigantic_mother_askani/snapshot.json +++ b/server/src/do/group/migrations/20261014133419_previous_gorilla_man/snapshot.json @@ -1,7 +1,7 @@ { "version": "7", "dialect": "sqlite", - "id": "09fbe554-2e51-414e-935c-d7812dbbe577", + "id": "16bacb0a-1384-44b2-bf4b-85593943c828", "prevIds": [ "00000000-0000-0000-0000-000000000000" ], @@ -804,16 +804,6 @@ "entityType": "columns", "table": "meta" }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "archived_at", - "entityType": "columns", - "table": "meta" - }, { "type": "text", "notNull": true, diff --git a/server/src/do/group/migrations/migrations.js b/server/src/do/group/migrations/migrations.js index d6d50d0..fdce409 100644 --- a/server/src/do/group/migrations/migrations.js +++ b/server/src/do/group/migrations/migrations.js @@ -1,8 +1,8 @@ -import m0000 from './20261005061632_gigantic_mother_askani/migration.sql'; +import m0000 from './20261014133419_previous_gorilla_man/migration.sql'; export default { migrations: { - "20261005061632_gigantic_mother_askani": m0000 + "20261014133419_previous_gorilla_man": m0000 } } \ No newline at end of file diff --git a/server/src/do/group/roster.ts b/server/src/do/group/roster.ts index 65e6ef7..6aacfbf 100644 --- a/server/src/do/group/roster.ts +++ b/server/src/do/group/roster.ts @@ -1,12 +1,12 @@ -import { and, eq, isNotNull, ne } from "drizzle-orm"; +import { and, eq, isNotNull, isNull, ne } from "drizzle-orm"; import * as schema from "../../db/group/schema"; import type { Group, GroupInput, Member, MemberInput } from "../../schemas/ledger"; -import { isSettled } from "./balances"; +import { isGroupSettled, isSettled } from "./balances"; import { append } from "./events"; import { refuse } from "./refusal"; import { findMemberByProfile, findMeta, nextSeq, purge, requireMember, requireMeta, type Tx, type WriteContext } from "./store"; -import { stageMembership, stagePurge } from "./upkeep"; +import { collectUnowned, stageMembership, stagePurge, touchActivity } from "./upkeep"; /** * The group and its people. Membership is the only permission: no owner, no @@ -44,27 +44,26 @@ function appearanceOf(input: GroupInput) { } /** - * Rename, archive or restore, settings, and how it looks. Any member; all of + * Shared settings and appearance. Any active member; all of * it is visible and reversible. A new look is not an event: the activity feed * records what happened to the money and the people, and a picture is neither. */ export function updateGroup(tx: Tx, input: GroupInput, { now, actor }: WriteContext): Group { const meta = requireMeta(tx); - const { name, simplifyDebts, archivedAt } = input; + const { name, simplifyDebts } = input; const appearance = appearanceOf(input); - const unchanged = name === meta.name && simplifyDebts === meta.simplifyDebts && archivedAt === meta.archivedAt && Object.entries(appearance).every(([column, value]) => meta[column as keyof typeof appearance] === value); + const unchanged = name === meta.name && simplifyDebts === meta.simplifyDebts && Object.entries(appearance).every(([column, value]) => meta[column as keyof typeof appearance] === value); if (unchanged) return meta; + if (input.baseSeq !== meta.seq) refuse("stale_base", "This group's settings changed. Refresh them before trying again."); const seq = nextSeq(tx); tx.update(schema.meta) - .set({ name, simplifyDebts, archivedAt, ...appearance, updatedAt: now, seq }) + .set({ name, simplifyDebts, ...appearance, updatedAt: now, seq }) .where(eq(schema.meta.id, meta.id)) .run(); const written = { seq, now, actorId: actor.id }; if (name !== meta.name) append(tx, { ...written, kind: "group_renamed", group: { name, previousName: meta.name } }); - if (archivedAt !== null && meta.archivedAt === null) append(tx, { ...written, kind: "group_archived", group: { name, previousName: null } }); - if (archivedAt === null && meta.archivedAt !== null) append(tx, { ...written, kind: "group_restored", group: { name, previousName: null } }); return requireMeta(tx); } @@ -113,6 +112,16 @@ function updateMember(tx: Tx, memberId: string, input: MemberInput, { now, actor refuse("forbidden", `${target.displayName} left this group, so only they can come back, with a link.`); } if (displayName === target.displayName && upiVpa === target.upiVpa && !leaving && !returning) return target; + if (input.baseSeq !== target.seq) refuse("stale_base", "This member changed. Refresh the group before trying again."); + if (leaving && target.profileId !== null) { + const otherActive = tx + .select({ id: schema.members.id }) + .from(schema.members) + .where(and(isNotNull(schema.members.profileId), isNull(schema.members.leftAt), ne(schema.members.id, target.id))) + .limit(1) + .get(); + if (!otherActive && !isGroupSettled(tx)) refuse("not_settled", "Settle every balance before the last account leaves and deletes this group."); + } const seq = nextSeq(tx); tx.update(schema.members).set({ displayName, upiVpa, leftAt, updatedAt: now, seq }).where(eq(schema.members.id, memberId)).run(); @@ -124,7 +133,9 @@ function updateMember(tx: Tx, memberId: string, input: MemberInput, { now, actor if (displayName !== target.displayName) append(tx, { ...written, kind: "member_renamed", member: { displayName, previousName: target.displayName } }); if (target.profileId !== null) stageMembership(tx, target.profileId, leftAt, now); - return requireMember(tx, memberId); + const result = requireMember(tx, memberId); + if (leaving) collectUnowned(tx, Date.parse(now)); + return result; } /** @@ -140,7 +151,7 @@ export function forgetProfile(tx: Tx, profileId: string, displayName: string | n const others = tx .select({ id: schema.members.id }) .from(schema.members) - .where(and(isNotNull(schema.members.profileId), ne(schema.members.profileId, profileId))) + .where(and(isNotNull(schema.members.profileId), isNull(schema.members.leftAt), ne(schema.members.profileId, profileId))) .limit(1) .get(); @@ -154,6 +165,7 @@ export function forgetProfile(tx: Tx, profileId: string, displayName: string | n .set({ profileId: null, displayName: displayName ?? member.displayName, updatedAt: now, seq: nextSeq(tx) }) .where(eq(schema.members.id, member.id)) .run(); + touchActivity(tx, now); return { forgotten: true, purged: false }; } @@ -167,12 +179,23 @@ export function handOver(tx: Tx, profileId: string, heirId: string, now: string) const meta = findMeta(tx); const member = meta && findMemberByProfile(tx, profileId); if (!meta || !member) return { forgotten: false, purged: false }; - if (findMemberByProfile(tx, heirId)) return forgetProfile(tx, profileId, null, now); + const heir = findMemberByProfile(tx, heirId); + if (heir) { + // Signing in transfers an active guest's access to the same person's account. + if (member.leftAt === null && heir.leftAt !== null) { + const seq = nextSeq(tx); + tx.update(schema.members).set({ leftAt: null, updatedAt: now, seq }).where(eq(schema.members.id, heir.id)).run(); + append(tx, { seq, now, actorId: null, kind: "member_joined", subjectId: heir.id, member: { displayName: heir.displayName, previousName: null } }); + stageMembership(tx, heirId, null, now); + } + return forgetProfile(tx, profileId, null, now); + } tx.update(schema.members) .set({ profileId: heirId, updatedAt: now, seq: nextSeq(tx) }) .where(eq(schema.members.id, member.id)) .run(); stageMembership(tx, heirId, member.leftAt, now); + touchActivity(tx, now); return { forgotten: true, purged: false }; } diff --git a/server/src/do/group/store.ts b/server/src/do/group/store.ts index 1fbaf18..7c6f2bd 100644 --- a/server/src/do/group/store.ts +++ b/server/src/do/group/store.ts @@ -45,7 +45,7 @@ export function findTombstone(tx: Tx) { export function requireMeta(tx: Tx): MetaRow { const meta = findMeta(tx); if (meta) return meta; - if (findTombstone(tx)) refuse("group_purged", "This group was settled and has been removed."); + if (findTombstone(tx)) refuse("group_purged", "This group has been removed."); refuse("no_group", "No such group."); } @@ -70,9 +70,16 @@ export function requireMember(tx: Tx, memberId: string): MemberRow { return member; } -/** Whether anybody here could still read this group. */ -export function hasAccountHolders(tx: Tx): boolean { - return tx.select({ id: schema.members.id }).from(schema.members).where(isNotNull(schema.members.profileId)).limit(1).get() !== undefined; +/** Whether an active account still participates. */ +export function hasActiveAccounts(tx: Tx): boolean { + return ( + tx + .select({ id: schema.members.id }) + .from(schema.members) + .where(and(isNotNull(schema.members.profileId), isNull(schema.members.leftAt))) + .limit(1) + .get() !== undefined + ); } /** Deletes the whole group, children first, leaving a tombstone for the feed. */ diff --git a/server/src/do/group/upkeep.ts b/server/src/do/group/upkeep.ts index 207db15..e222610 100644 --- a/server/src/do/group/upkeep.ts +++ b/server/src/do/group/upkeep.ts @@ -2,22 +2,10 @@ import { eq, inArray, sql } from "drizzle-orm"; import type { LinkKind } from "../../db/d1/schema"; import * as schema from "../../db/group/schema"; -import { isGroupSettled } from "./balances"; -import { append } from "./events"; -import { findMeta, hasAccountHolders, nextSeq, nowIso, purge, requireMeta, type Tx } from "./store"; +import { findMeta, hasActiveAccounts, nowIso, purge, requireMeta, type Tx } from "./store"; /** What the object owes D1 (the outbox) and its own future (the schedule behind its one alarm). */ -const DAY = 24 * 60 * 60 * 1000; - -/** Archiving is reversible; collecting waits four times as long and requires everyone settled. */ -export const DORMANCY = { - archiveAfter: 90 * DAY, - purgeAfter: 365 * DAY, - /** How long to wait before asking an unsettled dormant group again. */ - recheck: 30 * DAY, -} as const; - const RETRY_BACKOFF = [30_000, 2 * 60_000, 10 * 60_000, 60 * 60_000] as const; function scheduleAt(tx: Tx, name: schema.Chore, at: number): void { @@ -41,59 +29,22 @@ export function nextDue(tx: Tx): number | null { ); } -/** Called in the transaction of every change a member makes: a group in use is not dormant. */ -export function touchDormancy(tx: Tx, now: string): void { +/** Records when the shared ledger last changed. */ +export function touchActivity(tx: Tx, now: string): void { tx.update(schema.meta).set({ lastActivityAt: now }).run(); - scheduleAt(tx, "archive", Date.parse(now) + DORMANCY.archiveAfter); - unschedule(tx, "purge"); } export interface UpkeepOutcome { - archived: boolean; purged: boolean; } -/** Archive if quiet, collect if long dead and settled, and re-arm for whatever is next. */ -export function runDormancy(tx: Tx, now: number): UpkeepOutcome { - const outcome: UpkeepOutcome = { archived: false, purged: false }; +/** A group exists while an account participates, regardless of inactivity. */ +export function collectUnowned(tx: Tx, now: number): UpkeepOutcome { const meta = findMeta(tx); - if (!meta) return outcome; - - const quietSince = Date.parse(meta.lastActivityAt); - - if (meta.archivedAt === null) { - if (quietSince + DORMANCY.archiveAfter > now) { - scheduleAt(tx, "archive", quietSince + DORMANCY.archiveAfter); - return outcome; - } - - const seq = nextSeq(tx); - const at = nowIso(now); - tx.update(schema.meta).set({ archivedAt: at, updatedAt: at, seq }).where(eq(schema.meta.id, meta.id)).run(); - append(tx, { seq, now: at, actorId: null, kind: "group_archived", group: { name: meta.name, previousName: null } }); - outcome.archived = true; - } - // Archived, by this run or by a member: only collecting is left to wait for, and a spent - // archive deadline left behind would hold the alarm in the past. - unschedule(tx, "archive"); - - // Nobody left with an account: collect as soon as it is quiet. - const due = hasAccountHolders(tx) ? quietSince + DORMANCY.purgeAfter : quietSince; - if (due > now) { - scheduleAt(tx, "purge", due); - return outcome; - } - - if (!isGroupSettled(tx)) { - scheduleAt(tx, "purge", now + DORMANCY.recheck); - return outcome; - } - + if (!meta || hasActiveAccounts(tx)) return { purged: false }; purge(tx, nowIso(now)); stagePurge(tx, meta.id, nowIso(now)); - unschedule(tx, "purge"); - outcome.purged = true; - return outcome; + return { purged: true }; } export function stageMembership(tx: Tx, profileId: string, leftAt: string | null, now: string): void { diff --git a/server/src/openapi.ts b/server/src/openapi.ts index f57d9dd..cacb5d4 100644 --- a/server/src/openapi.ts +++ b/server/src/openapi.ts @@ -1,12 +1,15 @@ +import { apiBasePath } from "./paths"; + /** * The document's metadata, shared by the served `/api/openapi.json` and the * committed `docs/openapi.json`. OpenAPI 3.0.3: Dart generators handle 3.1 unevenly. */ export const openApiDocument = { openapi: "3.0.3", + servers: [{ url: apiBasePath }], info: { version: "1.0.0", title: "OpenSplit", - description: "The server stores rows and enforces one invariant. It computes nothing: splitting, folding balances, simplifying debts and analytics all happen on the device.", + description: "The server stores financial history and enforces membership, settlement and write concurrency rules. Splitting, debt simplification and analytics happen on the device.", }, -} as const; +}; diff --git a/server/src/paths.ts b/server/src/paths.ts new file mode 100644 index 0000000..1bf1fe9 --- /dev/null +++ b/server/src/paths.ts @@ -0,0 +1,8 @@ +/** The single supported app contract; lower positive versions are retired. */ +export const currentApiVersion = 1; + +/** Only this version can reach account and ledger services. */ +export const apiBasePath = `/api/v${currentApiVersion}`; + +/** Better Auth's browser callback lives under the same contract. */ +export const authBasePath = `${apiBasePath}/auth`; diff --git a/server/src/scheduled.ts b/server/src/scheduled.ts index f166444..50832b8 100644 --- a/server/src/scheduled.ts +++ b/server/src/scheduled.ts @@ -6,7 +6,7 @@ import { chunked } from "./chunked"; import { memberships, profiles } from "./db/d1/schema"; import { finishDeletions } from "./forget"; -/** The two crons. Dormant groups are not swept: each group object arms its own alarm. */ +/** The two crons. Groups remain while an account actively participates. */ export async function refreshRates(env: Env): Promise { const fx = env.FX.getByName("global"); diff --git a/server/src/schemas/common.ts b/server/src/schemas/common.ts index 739fc8f..2aaf703 100644 --- a/server/src/schemas/common.ts +++ b/server/src/schemas/common.ts @@ -56,7 +56,7 @@ export const SeqSchema = z.int().nonnegative().openapi({ example: 412 }); export const refusalCodes = [ "not_member", "no_group", - /** Archived, a year silent and settled, then collected. */ + /** The group has been permanently deleted. */ "group_purged", "no_such_entry", "no_such_member", @@ -83,9 +83,9 @@ export const RetrySchema = z.enum(["stale", "permanent", "transient"]).openapi(" description: "stale: re-read, re-compose and send again. permanent: this will be refused identically forever; do not retry. transient: back off and try the same request again.", }); -export const ErrorCodeSchema = z.enum([...refusalCodes, "no_session", "not_found", "internal", "identity_already_in_use", "auth_failed", "rate_limited", "reauth_required"]).openapi("ErrorCode"); +export const ErrorCodeSchema = z.enum([...refusalCodes, "no_session", "not_found", "update_required", "internal", "identity_already_in_use", "auth_failed", "rate_limited", "reauth_required"]).openapi("ErrorCode"); -/** Every failure's body. The client acts on `retry`, never on the status code. */ +/** Stable failure codes, with retry guidance for ordinary write refusals. */ export const ErrorSchema = z .object({ error: z.object({ diff --git a/server/src/schemas/ledger.ts b/server/src/schemas/ledger.ts index 304f01a..e228c5e 100644 --- a/server/src/schemas/ledger.ts +++ b/server/src/schemas/ledger.ts @@ -100,7 +100,7 @@ const memberRow = createSelectSchema(tables.members, { export const MemberSchema = memberRow.openapi("Member"); /** A member row at the id the device minted; a new one is a placeholder. No `profileId`: only an invite hands a place to an account. */ -export const MemberInputSchema = memberRow.pick({ displayName: true, upiVpa: true, leftAt: true }).openapi("MemberInput"); +export const MemberInputSchema = memberRow.pick({ displayName: true, upiVpa: true, leftAt: true }).extend({ baseSeq: SeqSchema.nullable() }).openapi("MemberInput"); const groupRow = createSelectSchema(tables.meta, { id: () => IdSchema, @@ -110,7 +110,6 @@ const groupRow = createSelectSchema(tables.meta, { ...coverRefinements, createdBy: () => IdSchema, createdAt: () => TimestampSchema, - archivedAt: () => TimestampSchema, updatedAt: () => TimestampSchema, lastActivityAt: () => TimestampSchema, seq: () => SeqSchema, @@ -124,8 +123,8 @@ export const GroupSchema = groupRow.openapi("Group"); * creator are read only then: afterwards they describe how the group began. */ export const GroupInputSchema = groupRow - .pick({ name: true, defaultCurrency: true, isDirect: true, simplifyDebts: true, archivedAt: true, ...avatarFields, ...coverFields }) - .extend({ creatorId: memberRow.shape.id, creatorName: memberRow.shape.displayName }) + .pick({ name: true, defaultCurrency: true, isDirect: true, simplifyDebts: true, ...avatarFields, ...coverFields }) + .extend({ creatorId: memberRow.shape.id, creatorName: memberRow.shape.displayName, baseSeq: SeqSchema.nullable() }) .superRefine(refineAvatar) .superRefine(refineCover) .openapi("GroupInput"); @@ -230,7 +229,6 @@ export const ChangePageSchema = z /** A page as the group's object answers it; the Worker adds the profiles, which live in D1. */ export type GroupChanges = Omit; - /** Where a device stands in one group's history. */ export const GroupCursorSchema = z.object({ groupId: IdSchema, since: SeqSchema }).openapi("GroupCursor"); @@ -252,7 +250,7 @@ export const GroupRefusalSchema = z.object({ groupId: IdSchema, code: ErrorCodeS export const PullSchema = z .object({ - /** Every group this account is in now, asked for or not: how a device finds a group joined elsewhere. */ + /** Every group this account actively participates in: how a device finds a group joined elsewhere. */ groupIds: z.array(IdSchema), /** One page per group read, in the order asked. */ pages: z.array(ChangePageSchema), diff --git a/server/test/account.test.ts b/server/test/account.test.ts index 09d05d3..7aef497 100644 --- a/server/test/account.test.ts +++ b/server/test/account.test.ts @@ -5,18 +5,16 @@ import { defaultAvatar, defaultGroupLook } from "../src/db/appearance"; import type { ApiError } from "../src/schemas/common"; import type { AccountDeletion, GroupLink, Joined, LinkPreview, Profile, ProfilePage } from "./api-types"; import { freshId } from "./group"; -import { changes, type Guest, pull, pullResponse, signInAsGuest } from "./session"; +import { apiUrl, changes, type Guest, pull, pullResponse, signInAsGuest } from "./session"; /** The person, rather than the ledger. */ -const ORIGIN = "https://opensplit.test"; - async function call(path: string, guest: Guest | null, init: RequestInit = {}) { const headers = new Headers(init.headers); headers.set("Content-Type", "application/json"); if (guest) headers.set("Authorization", `Bearer ${guest.token}`); - return workerExports.default.fetch(`${ORIGIN}${path}`, { ...init, headers }); + return workerExports.default.fetch(apiUrl(path), { ...init, headers }); } async function json(response: Response): Promise { @@ -25,9 +23,9 @@ async function json(response: Response): Promise { async function makeGroup(host: Guest) { const id = freshId("acct"); - const created = await call(`/api/groups/${id}`, host, { + const created = await call(`/groups/${id}`, host, { method: "PUT", - body: JSON.stringify({ name: "Goa trip", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: null, ...defaultGroupLook, creatorId: `${id}-host`, creatorName: "Ravi" }), + body: JSON.stringify({ name: "Goa trip", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, baseSeq: null, ...defaultGroupLook, creatorId: `${id}-host`, creatorName: "Ravi" }), }); expect(created.status).toBe(200); return id; @@ -36,16 +34,16 @@ async function makeGroup(host: Guest) { /** Puts two accounts in one group, the only way the app can: a link, spent. */ async function share(host: Guest, guest: Guest, name: string) { const groupId = await makeGroup(host); - const link = await json(await call(`/api/groups/${groupId}/link`, host, { method: "POST" })); + const link = await json(await call(`/groups/${groupId}/link`, host, { method: "POST" })); - const joined = await call(`/api/links/${link.token}/join`, guest, { method: "POST", body: JSON.stringify({ memberId: null, displayName: name }) }); + const joined = await call(`/links/${link.token}/join`, guest, { method: "POST", body: JSON.stringify({ memberId: null, displayName: name }) }); expect(joined.status).toBe(200); return { groupId, member: (await json(joined)).member }; } async function writeProfile(guest: Guest, displayName: string | null, upiVpa: string | null = null, look: Record = {}): Promise { - const response = await call("/api/profile", guest, { method: "PUT", body: JSON.stringify({ displayName, upiVpa, ...defaultAvatar, ...look }) }); + const response = await call("/profile", guest, { method: "PUT", body: JSON.stringify({ displayName, upiVpa, ...defaultAvatar, ...look }) }); expect(response.status).toBe(200); return json(response); } @@ -64,7 +62,7 @@ describe("your own profile", () => { // A guest has no name of its own, and that null is load-bearing: claiming // an invite adopts the name a friend typed only when there is none here. - const before = await json(await call("/api/profiles", me)); + const before = await json(await call("/profiles", me)); expect(before.profiles).toEqual([expect.objectContaining({ id: me.id, displayName: null, upiVpa: null, deletedAt: null })]); const written = await named(me, "Ravi"); @@ -91,7 +89,7 @@ describe("your own profile", () => { it("keeps the avatar somebody picks, and gives it to everyone who can see them", async () => { const me = await signInAsGuest(); - expect((await json(await call("/api/profiles", me))).profiles[0]).toMatchObject(defaultAvatar); + expect((await json(await call("/profiles", me))).profiles[0]).toMatchObject(defaultAvatar); const emoji = await writeProfile(me, "Ravi", null, { avatarKind: "emoji", avatarEmoji: "🏏", avatarColor: "teal" }); expect(emoji).toMatchObject({ avatarKind: "emoji", avatarEmoji: "🏏", avatarColor: "teal", avatarIcon: null }); @@ -102,7 +100,7 @@ describe("your own profile", () => { }); it("refuses an avatar whose fields disagree with its kind, or that is not what it says", async () => { - const write = (look: Record) => call("/api/profile", ravi, { method: "PUT", body: JSON.stringify({ displayName: "Ravi", upiVpa: null, ...defaultAvatar, ...look }) }); + const write = (look: Record) => call("/profile", ravi, { method: "PUT", body: JSON.stringify({ displayName: "Ravi", upiVpa: null, ...defaultAvatar, ...look }) }); expect((await write({ avatarKind: "emoji" })).status).toBe(400); expect((await write({ avatarEmoji: "🏏" })).status).toBe(400); @@ -116,27 +114,27 @@ describe("your own profile", () => { }); it("has room for a photo, and refuses one while nothing can be uploaded", async () => { - const refused = await call("/api/profile", ravi, { method: "PUT", body: JSON.stringify({ displayName: "Ravi", upiVpa: null, ...defaultAvatar, avatarKind: "photo", avatarPhoto: "avatars/me/1.webp" }) }); + const refused = await call("/profile", ravi, { method: "PUT", body: JSON.stringify({ displayName: "Ravi", upiVpa: null, ...defaultAvatar, avatarKind: "photo", avatarPhoto: "avatars/me/1.webp" }) }); expect(refused.status).toBe(400); }); it("forgets the avatar with the rest of a deleted account", async () => { const me = await signInAsGuest(); await writeProfile(me, "Ravi", null, { avatarKind: "icon", avatarIcon: "sailing", avatarColor: "blue" }); - expect((await call("/api/account", me, { method: "DELETE" })).status).toBe(200); + expect((await call("/account", me, { method: "DELETE" })).status).toBe(200); const left = await env.DB.prepare("select avatar_kind, avatar_icon, avatar_color from profiles where id = ?").bind(me.id).first(); expect(left).toEqual({ avatar_kind: "initials", avatar_icon: null, avatar_color: null }); }); it("refuses a payment handle that is not one", async () => { - const refused = await call("/api/profile", ravi, { method: "PUT", body: JSON.stringify({ displayName: "Ravi", upiVpa: "not a vpa", ...defaultAvatar }) }); + const refused = await call("/profile", ravi, { method: "PUT", body: JSON.stringify({ displayName: "Ravi", upiVpa: "not a vpa", ...defaultAvatar }) }); expect(refused.status).toBe(400); expect((await json(refused)).error.code).toBe("malformed"); }); it("requires both fields, so neither can be silently left behind", async () => { - const refused = await call("/api/profile", ravi, { method: "PUT", body: JSON.stringify({ displayName: "Ravi" }) }); + const refused = await call("/profile", ravi, { method: "PUT", body: JSON.stringify({ displayName: "Ravi" }) }); expect(refused.status).toBe(400); }); }); @@ -153,7 +151,7 @@ describe("the profile feed", () => { await share(host, friend, "Priya"); - const seen = await json(await call("/api/profiles", host)); + const seen = await json(await call("/profiles", host)); const ids = seen.profiles.map((row) => row.id); expect(ids).toContain(host.id); @@ -162,7 +160,7 @@ describe("the profile feed", () => { // Symmetric, which is what makes a settle-up possible: Priya needs Ravi's // payment handle exactly as much as Ravi needs hers. - const other = await json(await call("/api/profiles", friend)); + const other = await json(await call("/profiles", friend)); expect(other.profiles.map((row) => row.id)).toContain(host.id); }); @@ -171,9 +169,9 @@ describe("the profile feed", () => { const friends = [await signInAsGuest(), await signInAsGuest(), await signInAsGuest()]; const groupId = await makeGroup(host); - const link = await json(await call(`/api/groups/${groupId}/link`, host, { method: "POST" })); + const link = await json(await call(`/groups/${groupId}/link`, host, { method: "POST" })); for (const [index, friend] of friends.entries()) { - await call(`/api/links/${link.token}/join`, friend, { method: "POST", body: JSON.stringify({ memberId: null, displayName: `Friend ${index}` }) }); + await call(`/links/${link.token}/join`, friend, { method: "POST", body: JSON.stringify({ memberId: null, displayName: `Friend ${index}` }) }); await named(friend, `Friend ${index}`); } @@ -182,7 +180,7 @@ describe("the profile feed", () => { let pages = 0; while (page.hasMore && pages < 10) { - page = await json(await call(`/api/profiles?limit=2&since=${page.seq}`, host)); + page = await json(await call(`/profiles?limit=2&since=${page.seq}`, host)); collected.push(...page.profiles.map((row) => row.id)); pages += 1; } @@ -213,10 +211,10 @@ describe("a group's change page", () => { it("carries more profiles than D1 binds in one query", async () => { const host = await signInAsGuest(); const groupId = await makeGroup(host); - const link = await json(await call(`/api/groups/${groupId}/link`, host, { method: "POST" })); + const link = await json(await call(`/groups/${groupId}/link`, host, { method: "POST" })); const arrivals = await Promise.all(Array.from({ length: 120 }, () => signInAsGuest())); for (const [index, guest] of arrivals.entries()) { - await call(`/api/links/${link.token}/join`, guest, { method: "POST", body: JSON.stringify({ memberId: null, displayName: `Friend ${index}` }) }); + await call(`/links/${link.token}/join`, guest, { method: "POST", body: JSON.stringify({ memberId: null, displayName: `Friend ${index}` }) }); } const page = await changes(host, groupId); @@ -233,7 +231,7 @@ describe("a group's change page", () => { await named(friend, "Priya"); await named(host, "Ravi"); - const swept = await json(await call("/api/profiles", host)); + const swept = await json(await call("/profiles", host)); expect(swept.profiles.map((row) => row.id)).not.toContain(friend.id); const { groupId } = await share(host, friend, "Priya"); @@ -249,23 +247,23 @@ describe("devices", () => { const second = await signInAsGuest(); const token = `fcm-${freshId("dev")}`; - expect((await call("/api/devices", first, { method: "PUT", body: JSON.stringify({ token, platform: "android" }) })).status).toBe(200); + expect((await call("/devices", first, { method: "PUT", body: JSON.stringify({ token, platform: "android" }) })).status).toBe(200); // Idempotent: a token is re-registered on every launch. - expect((await call("/api/devices", first, { method: "PUT", body: JSON.stringify({ token, platform: "android" }) })).status).toBe(200); + expect((await call("/devices", first, { method: "PUT", body: JSON.stringify({ token, platform: "android" }) })).status).toBe(200); // A phone that changes hands keeps its registration token, so the claim has // to transfer rather than be refused — otherwise the previous owner's // notifications follow the new one. - expect((await call("/api/devices", second, { method: "PUT", body: JSON.stringify({ token, platform: "android" }) })).status).toBe(200); + expect((await call("/devices", second, { method: "PUT", body: JSON.stringify({ token, platform: "android" }) })).status).toBe(200); // Which means the previous owner can no longer forget it. - expect(await json<{ forgotten: boolean }>(await call(`/api/devices/${encodeURIComponent(token)}`, first, { method: "DELETE" }))).toEqual({ forgotten: false }); - expect(await json<{ forgotten: boolean }>(await call(`/api/devices/${encodeURIComponent(token)}`, second, { method: "DELETE" }))).toEqual({ forgotten: true }); + expect(await json<{ forgotten: boolean }>(await call(`/devices/${encodeURIComponent(token)}`, first, { method: "DELETE" }))).toEqual({ forgotten: false }); + expect(await json<{ forgotten: boolean }>(await call(`/devices/${encodeURIComponent(token)}`, second, { method: "DELETE" }))).toEqual({ forgotten: true }); }); it("is refused without a session", async () => { - expect((await call("/api/devices", null, { method: "PUT", body: JSON.stringify({ token: "x", platform: "web" }) })).status).toBe(401); + expect((await call("/devices", null, { method: "PUT", body: JSON.stringify({ token: "x", platform: "web" }) })).status).toBe(401); }); }); @@ -277,7 +275,7 @@ describe("deleting an account", () => { const { groupId, member } = await share(staying, leaving, "Ravi"); - const deleted = await json(await call("/api/account", leaving, { method: "DELETE" })); + const deleted = await json(await call("/account", leaving, { method: "DELETE" })); expect(deleted).toEqual({ forgotten: 1, purged: 0 }); // The group is untouched for everybody else, and the member row keeps its @@ -297,7 +295,7 @@ describe("deleting an account", () => { const { groupId, member } = await share(staying, leaving, "Ravi"); await named(leaving, "Ravi Kumar"); - await call("/api/account", leaving, { method: "DELETE" }); + await call("/account", leaving, { method: "DELETE" }); const page = await changes(staying, groupId); expect(page.members.find((each) => each.id === member.id)?.displayName).toBe("Ravi Kumar"); @@ -307,7 +305,7 @@ describe("deleting an account", () => { const solo = await signInAsGuest(); const groupId = await makeGroup(solo); - const deleted = await json(await call("/api/account", solo, { method: "DELETE" })); + const deleted = await json(await call("/account", solo, { method: "DELETE" })); expect(deleted).toEqual({ forgotten: 1, purged: 1 }); // Holding somebody's expense descriptions forever in a group with no living @@ -326,7 +324,7 @@ describe("deleting an account", () => { await writeProfile(going, "Zara", "zara@okaxis"); const token = `fcm-${freshId("gone")}`; - await call("/api/devices", going, { method: "PUT", body: JSON.stringify({ token, platform: "android" }) }); + await call("/devices", going, { method: "PUT", body: JSON.stringify({ token, platform: "android" }) }); // A group with somebody else in it, so the account survives long enough // for its own profile to be checked from the other side. @@ -334,7 +332,7 @@ describe("deleting an account", () => { await named(friend, "Priya"); const { groupId } = await share(friend, going, "Zara"); - expect((await call("/api/account", going, { method: "DELETE" })).status).toBe(200); + expect((await call("/account", going, { method: "DELETE" })).status).toBe(200); // The session is gone with the account, so the device is signed out by // consequence rather than by a second call. @@ -346,7 +344,7 @@ describe("deleting an account", () => { }); it("is refused without a session", async () => { - expect((await call("/api/account", null, { method: "DELETE" })).status).toBe(401); + expect((await call("/account", null, { method: "DELETE" })).status).toBe(401); }); }); @@ -357,7 +355,7 @@ describe("a guest who never became anybody", () => { // Visible only to themselves, which is the degenerate case of the rule // rather than a special one: nobody shares a group with them yet. - const page = await json(await call("/api/profiles", guest)); + const page = await json(await call("/profiles", guest)); expect(page.profiles.map((row) => row.id)).toEqual([guest.id]); }); }); @@ -368,8 +366,8 @@ describe("a link preview", () => { const friend = await signInAsGuest(); const { groupId } = await share(host, friend, "Priya"); - const link = await json(await call(`/api/groups/${groupId}/link`, host, { method: "POST" })); - const preview = await json(await call(`/api/links/${link.token}`, friend)); + const link = await json(await call(`/groups/${groupId}/link`, host, { method: "POST" })); + const preview = await json(await call(`/links/${link.token}`, friend)); expect(preview.isMember).toBe(true); expect(preview.memberCount).toBe(2); diff --git a/server/test/activity.test.ts b/server/test/activity.test.ts index 5727734..7361ac6 100644 --- a/server/test/activity.test.ts +++ b/server/test/activity.test.ts @@ -143,27 +143,20 @@ describe("member and group life", () => { expect(line?.subjectId).toBe(priya.id); }); - it("records a rename and an archive from one patch as two things, not one", async () => { + it("records a shared rename", async () => { const { groupId } = await makeGroup(); - ok(await editGroup(groupId, RAVI, { name: "Goa, closed", archivedAt: new Date().toISOString() })); + ok(await editGroup(groupId, RAVI, { name: "Goa, closed" })); const events = await eventsOf(groupId, RAVI); const kinds = events.map((event) => event.kind); expect(kinds).toContain("group_renamed"); - expect(kinds).toContain("group_archived"); const renamed = events.filter((event) => event.group !== null).findLast((event) => event.kind === "group_renamed"); expect(renamed?.group?.previousName).toBe("Goa trip"); - /** - * Both lines share a sequence number and a timestamp, because both are - * taken once per change. `ordinal` is what stops the feed rendering them - * in whichever order a random id happened to sort in. - */ const together = events.filter((event) => event.seq === renamed?.seq); - expect(together).toHaveLength(2); - expect(together.map((event) => event.ordinal)).toEqual([0, 1]); - expect(together.map((event) => event.kind)).toEqual(["group_renamed", "group_archived"]); + expect(together).toHaveLength(1); + expect(together.map((event) => event.kind)).toEqual(["group_renamed"]); }); it("says nothing about a payment handle: it is personal bookkeeping", async () => { @@ -174,30 +167,3 @@ describe("member and group life", () => { expect((await eventsOf(groupId, RAVI)).length).toBe(before); }); }); - -describe("an archived group that comes back", () => { - it("records the expense that revived it, not 'somebody restored the group'", async () => { - const { groupId, ravi, priya } = await makeGroup(); - const object = stub(groupId); - - ok(await editGroup(groupId, RAVI, { archivedAt: new Date().toISOString() })); - ok(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id, priya.id], 300), RAVI)); - - const kinds = (await eventsOf(groupId, RAVI)).map((event) => event.kind); - expect(kinds).toContain("group_archived"); - expect(kinds).not.toContain("group_restored"); - expect(kinds.at(-1)).toBe("entry"); - - const page = ok(await object.changes(RAVI, 0, 500)); - expect(page.group?.archivedAt).toBeNull(); - }); - - it("but records somebody un-archiving it on purpose", async () => { - const { groupId } = await makeGroup(); - - ok(await editGroup(groupId, RAVI, { archivedAt: new Date().toISOString() })); - ok(await editGroup(groupId, RAVI, { archivedAt: null })); - - expect((await eventsOf(groupId, RAVI)).map((event) => event.kind)).toContain("group_restored"); - }); -}); diff --git a/server/test/api.test.ts b/server/test/api.test.ts index 75dddd7..0d1020e 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -5,18 +5,16 @@ import { defaultGroupLook } from "../src/db/appearance"; import type { ApiError } from "../src/schemas/common"; import type { Entry, Group, Member } from "./api-types"; import { freshId } from "./group"; -import { changes, type Guest, pull, pullResponse, signInAsGuest } from "./session"; +import { apiUrl, changes, type Guest, pull, pullResponse, signInAsGuest } from "./session"; /** The sync surface over HTTP. */ -const ORIGIN = "https://opensplit.test"; - async function call(path: string, guest: Guest | null, init: RequestInit = {}) { const headers = new Headers(init.headers); headers.set("Content-Type", "application/json"); if (guest) headers.set("Authorization", `Bearer ${guest.token}`); - return workerExports.default.fetch(`${ORIGIN}${path}`, { ...init, headers }); + return workerExports.default.fetch(apiUrl(path), { ...init, headers }); } async function json(response: Response): Promise { @@ -25,9 +23,9 @@ async function json(response: Response): Promise { async function makeGroup(guest: Guest, name = "Goa trip") { const id = freshId("api"); - const response = await call(`/api/groups/${id}`, guest, { + const response = await call(`/groups/${id}`, guest, { method: "PUT", - body: JSON.stringify({ name, defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: null, ...defaultGroupLook, creatorId: `${id}-me`, creatorName: "Ravi" }), + body: JSON.stringify({ name, defaultCurrency: "INR", isDirect: false, simplifyDebts: true, baseSeq: null, ...defaultGroupLook, creatorId: `${id}-me`, creatorName: "Ravi" }), }); expect(response.status).toBe(200); @@ -62,7 +60,7 @@ let zara: Guest; /** Writes an expense at its own id, as Ravi. */ function putEntry(groupId: string, { id, ...body }: { id: string } & Record) { - return call(`/api/groups/${groupId}/entries/${id}`, ravi, { method: "PUT", body: JSON.stringify(body) }); + return call(`/groups/${groupId}/entries/${id}`, ravi, { method: "PUT", body: JSON.stringify(body) }); } beforeAll(async () => { @@ -76,7 +74,7 @@ describe("without a session", () => { expect(pulled.status).toBe(401); expect((await json(pulled)).error.code).toBe("no_session"); - const write = await call(`/api/groups/${freshId()}`, null, { method: "PUT", body: JSON.stringify({}) }); + const write = await call(`/groups/${freshId()}`, null, { method: "PUT", body: JSON.stringify({}) }); expect(write.status).toBe(401); }); }); @@ -269,11 +267,12 @@ describe("the roster over HTTP", () => { const { id } = await makeGroup(ravi); const memberId = freshId("api-m"); - const added = await call(`/api/groups/${id}/members/${memberId}`, ravi, { method: "PUT", body: JSON.stringify({ displayName: "Priya", upiVpa: null, leftAt: null }) }); + const added = await call(`/groups/${id}/members/${memberId}`, ravi, { method: "PUT", body: JSON.stringify({ displayName: "Priya", upiVpa: null, leftAt: null, baseSeq: null }) }); expect(added.status).toBe(200); - expect((await json(added)).profileId).toBeNull(); + const member = await json(added); + expect(member.profileId).toBeNull(); - const patched = await call(`/api/groups/${id}/members/${memberId}`, ravi, { method: "PUT", body: JSON.stringify({ displayName: "Priya", upiVpa: "priya@okaxis", leftAt: null }) }); + const patched = await call(`/groups/${id}/members/${memberId}`, ravi, { method: "PUT", body: JSON.stringify({ displayName: "Priya", upiVpa: "priya@okaxis", leftAt: null, baseSeq: member.seq }) }); expect(patched.status).toBe(200); expect((await json(patched)).upiVpa).toBe("priya@okaxis"); }); @@ -281,15 +280,16 @@ describe("the roster over HTTP", () => { it("refuses a payment handle that is not one, before the object sees it", async () => { const { id } = await makeGroup(ravi); const memberId = freshId("api-m"); - await call(`/api/groups/${id}/members/${memberId}`, ravi, { method: "PUT", body: JSON.stringify({ displayName: "Priya", upiVpa: null, leftAt: null }) }); + await call(`/groups/${id}/members/${memberId}`, ravi, { method: "PUT", body: JSON.stringify({ displayName: "Priya", upiVpa: null, leftAt: null, baseSeq: null }) }); - const response = await call(`/api/groups/${id}/members/${memberId}`, ravi, { method: "PUT", body: JSON.stringify({ displayName: "Priya", upiVpa: "not a handle", leftAt: null }) }); + const response = await call(`/groups/${id}/members/${memberId}`, ravi, { method: "PUT", body: JSON.stringify({ displayName: "Priya", upiVpa: "not a handle", leftAt: null, baseSeq: null }) }); expect(response.status).toBe(400); }); it("changes how a group looks without telling the activity feed", async () => { const { id } = await makeGroup(ravi); - const look = (look: Record) => call(`/api/groups/${id}`, ravi, { method: "PUT", body: JSON.stringify({ name: "Goa trip", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: null, ...defaultGroupLook, creatorId: "unused", creatorName: "Ravi", ...look }) }); + const look = async (look: Record) => + call(`/groups/${id}`, ravi, { method: "PUT", body: JSON.stringify({ name: "Goa trip", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, baseSeq: (await changes(ravi, id)).group?.seq, ...defaultGroupLook, creatorId: "unused", creatorName: "Ravi", ...look }) }); const events = async () => (await changes(ravi, id)).events.length; const before = await events(); @@ -306,10 +306,10 @@ describe("the roster over HTTP", () => { it("renames a group, and refuses a blank name", async () => { const { id } = await makeGroup(ravi); - const renamed = await call(`/api/groups/${id}`, ravi, { method: "PUT", body: JSON.stringify({ name: "Goa, take two", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: null, ...defaultGroupLook, creatorId: "unused", creatorName: "Ravi" }) }); + const renamed = await call(`/groups/${id}`, ravi, { method: "PUT", body: JSON.stringify({ name: "Goa, take two", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, baseSeq: (await changes(ravi, id)).group?.seq, ...defaultGroupLook, creatorId: "unused", creatorName: "Ravi" }) }); expect((await json(renamed)).name).toBe("Goa, take two"); - const blank = await call(`/api/groups/${id}`, ravi, { method: "PUT", body: JSON.stringify({ name: " ", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: null, ...defaultGroupLook, creatorId: "unused", creatorName: "Ravi" }) }); + const blank = await call(`/groups/${id}`, ravi, { method: "PUT", body: JSON.stringify({ name: " ", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, baseSeq: (await changes(ravi, id)).group?.seq, ...defaultGroupLook, creatorId: "unused", creatorName: "Ravi" }) }); expect(blank.status).toBe(400); }); }); @@ -334,7 +334,7 @@ describe("which group a page is about", () => { it("is still stated by a group that has been collected", async () => { const { id } = await makeGroup(ravi); - await env.GROUP.getByName(id).runUpkeep(Date.now() + 800 * 24 * 60 * 60 * 1000); + await env.GROUP.getByName(id).forgetProfile(ravi.id, null); const page = await changes(ravi, id); expect(page.groupId).toBe(id); diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts index e18a506..114a854 100644 --- a/server/test/auth.test.ts +++ b/server/test/auth.test.ts @@ -2,7 +2,7 @@ import { env, exports as workerExports } from "cloudflare:workers"; import { describe, expect, it } from "vitest"; import type { Session } from "../src/schemas/identity"; -import { signInAsGuest } from "./session"; +import { apiUrl, signInAsGuest } from "./session"; interface ProfileRow { id: string; @@ -40,7 +40,7 @@ describe("guest accounts", () => { it("resolves the session from a bearer token", async () => { const guest = await signInAsGuest(); - const response = await workerExports.default.fetch("https://opensplit.test/api/identity/session", { headers: { Authorization: `Bearer ${guest.token}` } }); + const response = await workerExports.default.fetch(apiUrl("/identity/session"), { headers: { Authorization: `Bearer ${guest.token}` } }); expect(response.status).toBe(200); const body = (await response.json()) as Session; diff --git a/server/test/contract.test.ts b/server/test/contract.test.ts new file mode 100644 index 0000000..91c1299 --- /dev/null +++ b/server/test/contract.test.ts @@ -0,0 +1,84 @@ +import { runInDurableObject } from "cloudflare:test"; +import { env, exports as workerExports } from "cloudflare:workers"; +import { expect, it } from "vitest"; + +import { makeGroup, ok, stub } from "./group"; +import { apiUrl, signInAsGuest } from "./session"; + +it("unversioned and unavailable APIs return 404 without changing account or group data", async () => { + const guest = await signInAsGuest(); + const fixture = await makeGroup({ owner: guest.id }); + const id = fixture.groupId; + const memberId = fixture.ravi.id; + const d1Snapshot = async () => { + const tables = ["user", "account", "session", "verification", "profiles", "memberships", "device_tokens", "link_tokens", "purged_groups", "counters"]; + return Promise.all(tables.map(async (table) => (await env.DB.prepare(`SELECT * FROM ${table} ORDER BY 1`).all()).results)); + }; + const groupSnapshot = () => + runInDurableObject(stub(id), (_instance, ctx) => { + const tables = ["meta", "members", "entries", "entry_payers", "entry_shares", "events", "counter", "outbox", "schedule", "invites", "group_link", "tombstone"]; + return tables.map((table) => ctx.storage.sql.exec(`SELECT * FROM ${table} ORDER BY 1`).toArray()); + }); + const beforeD1 = await d1Snapshot(); + const beforeGroup = await groupSnapshot(); + const entry = { + kind: "expense", + description: "Old queued edit", + categoryId: null, + currency: "INR", + amountMinor: 100, + entryDate: "2026-10-14", + occurredAt: null, + timeZone: null, + splitKind: "exact", + fxRate: null, + fxSource: null, + notes: null, + deletedAt: null, + baseSeq: null, + payers: [{ memberId, amountMinor: 100 }], + shares: [{ memberId, amountMinor: 100, weightMicros: null }], + }; + const requests: [string, string, object?][] = [ + ["POST", "/api/identity/guest"], + ["POST", "/api/identity/email", { email: "old-client@example.invalid" }], + ["POST", "/api/identity/google", { idToken: "old-token" }], + ["POST", "/api/identity/sign-out"], + ["POST", "/api/identity/reauth"], + ["GET", "/api/auth/callback/google"], + ["POST", "/api/auth/callback/google", { code: "old-code", state: "old-state" }], + ["GET", "/api/auth/error"], + ["POST", "/api/sync", { groups: [{ groupId: id, since: 0 }], limit: 200 }], + ["PUT", `/api/groups/${id}`, { name: "Stale group name", archivedAt: null }], + ["PUT", `/api/groups/${id}/members/${memberId}`, { displayName: "Stale member", upiVpa: null, leftAt: null }], + // This expense would still validate under the current contract. + ["PUT", `/api/groups/${id}/entries/old-expense`, entry], + ["POST", `/api/groups/${id}/link`], + ["POST", `/api/groups/${id}/members/${fixture.priya.id}/invite`], + ["POST", "/api/links/old-token/join", { memberId: null, displayName: null }], + ["PUT", "/api/profile", { displayName: "Old profile edit" }], + ["PUT", "/api/devices", { token: "old-push-token", platform: "android" }], + ["DELETE", "/api/account"], + ["PUT", `/api/v999/groups/${id}/entries/unknown-version`, entry], + ]; + for (const [method, path, body] of requests) { + const response = await workerExports.default.fetch(`https://opensplit.test${path}`, { + method, + headers: { Authorization: `Bearer ${guest.token}`, "Content-Type": "application/json" }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + expect(response.status, `${method} ${path}`).toBe(404); + expect(await response.json()).toMatchObject({ error: { code: "not_found", retry: "permanent" } }); + } + expect(await d1Snapshot()).toEqual(beforeD1); + expect(await groupSnapshot()).toEqual(beforeGroup); + expect(ok(await stub(id).changes(guest.id, 0, 200)).members).toHaveLength(2); + const session = await workerExports.default.fetch(apiUrl("/identity/session"), { headers: { Authorization: `Bearer ${guest.token}` } }); + expect(await session.json()).toMatchObject({ account: { id: guest.id } }); +}); + +it("unknown operations in the supported contract remain ordinary 404s", async () => { + const response = await workerExports.default.fetch(apiUrl("/missing-operation")); + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ error: { code: "not_found" } }); +}); diff --git a/server/test/dormancy.test.ts b/server/test/dormancy.test.ts index ae6e35f..6877af9 100644 --- a/server/test/dormancy.test.ts +++ b/server/test/dormancy.test.ts @@ -1,10 +1,7 @@ -import { runInDurableObject } from "cloudflare:test"; import { env } from "cloudflare:workers"; import { describe, expect, it } from "vitest"; -import { defaultGroupLook } from "../src/db/appearance"; -import type { Group } from "../src/do/group"; -import { deleteEntry, draftOf, editMember, evenly, freshId, makeGroup, makeGroupOfTwo, ok, PRIYA, RAVI, refusal, saveEntry, stub } from "./group"; +import { deleteEntry, editMember, evenly, freshId, makeGroup, makeGroupOfTwo, ok, PRIYA, RAVI, saveEntry, stub } from "./group"; /** Dormancy and account deletion: what an idle group does to itself, and what survives somebody deleting the account that made it. */ @@ -15,159 +12,17 @@ async function membershipsIn(groupId: string): Promise<{ profile_id: string; lef return rows.results; } -describe("a group that goes quiet", () => { - it("is put away after three months", async () => { - const { groupId } = await makeGroup(); - const outcome = await stub(groupId).runUpkeep(Date.now() + 100 * DAYS); - - expect(outcome.archived).toBe(true); - expect(ok(await stub(groupId).changes(RAVI, 0, 500)).group?.archivedAt).not.toBeNull(); - }); - - it("is left alone while somebody is still using it", async () => { - const { groupId } = await makeGroup(); - const outcome = await stub(groupId).runUpkeep(Date.now() + 10 * DAYS); - - expect(outcome.archived).toBe(false); - }); - - /** - * Archiving is reversible precisely so that being wrong about it costs the - * people in the group nothing. - */ - it("comes back by itself the moment somebody adds an expense, with nothing to undo", async () => { - const { groupId, ravi, priya } = await makeGroup(); - const object = stub(groupId); - - await object.runUpkeep(Date.now() + 100 * DAYS); - ok(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id, priya.id], 400), RAVI)); - - expect(ok(await object.changes(RAVI, 0, 500)).group?.archivedAt).toBeNull(); - }); - - /** - * Quiet is measured from the last change anybody made, not from the newest - * expense: a group whose people spend months correcting old expenses, or - * only joining and renaming, is in use. - */ - it("counts an edit to an old expense as use", async () => { - const { groupId, ravi, priya } = await makeGroup(); - const object = stub(groupId); - const entry = ok(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id, priya.id], 400), RAVI)); - - // Recorded, and last touched, half a year ago. - const longAgo = Date.now() - 180 * DAYS; - await runInDurableObject(object, async (_instance: Group, state) => { - state.storage.sql.exec("update entries set created_at = ?", new Date(longAgo).toISOString()); - state.storage.sql.exec("update meta set last_activity_at = ?", new Date(longAgo).toISOString()); - }); - ok(await saveEntry(object, draftOf(entry, { description: "Dinner, corrected" }), RAVI)); - - expect((await object.runUpkeep(Date.now() + 10 * DAYS)).archived).toBe(false); - }); - - it("does not count being archived as use", async () => { - const { groupId } = await makeGroup(); - const object = stub(groupId); - - await object.runUpkeep(Date.now() + 100 * DAYS); - const activity = await runInDurableObject(object, async (_instance: Group, state) => state.storage.sql.exec<{ last_activity_at: string }>("select last_activity_at from meta").one().last_activity_at); - expect(Date.parse(activity)).toBeLessThan(Date.now() + DAYS); - }); - - /** A deadline already spent would hold the object's one alarm in the past, firing again as soon as it returns. */ - it("leaves no archive deadline behind once it is archived, by anybody", async () => { - const { groupId, ravi, priya } = await makeGroup(); - const object = stub(groupId); - const entry = ok(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id, priya.id], 400), RAVI)); - ok(await object.putGroup(groupId, { name: "Goa trip", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: new Date().toISOString(), ...defaultGroupLook, creatorId: ravi.id, creatorName: "Ravi" }, RAVI)); - // Deleting is a change, so it arms the archive deadline again, in an archived group. - ok(await deleteEntry(object, entry, RAVI)); - - await object.runUpkeep(Date.now() + 100 * DAYS); - const chores = await runInDurableObject(object, async (_instance: Group, state) => - state.storage.sql - .exec<{ name: string }>("select name from schedule") - .toArray() - .map((row) => row.name), - ); - expect(chores).not.toContain("archive"); - }); - - it("tells the record that nobody archived it", async () => { - const { groupId } = await makeGroup(); - await stub(groupId).runUpkeep(Date.now() + 100 * DAYS); - - const events = ok(await stub(groupId).changes(RAVI, 0, 500)).events; - const archived = events.findLast((event) => event.kind === "group_archived"); - expect(archived?.actorId).toBeNull(); - }); -}); - -describe("a group long dead", () => { - /** - * The settled check is the whole safety of collection. The most likely - * reason a group goes quiet with a balance outstanding is that the debt is - * disputed or forgotten, which is exactly when erasing the record is worst. - */ - it("is never collected while anybody still owes anybody anything", async () => { - const { groupId, ravi, priya } = await makeGroup(); - const object = stub(groupId); - - ok(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id, priya.id], 1000), RAVI)); - - const outcome = await object.runUpkeep(Date.now() + 800 * DAYS); - expect(outcome.purged).toBe(false); - expect(ok(await object.changes(RAVI, 0, 500)).group).not.toBeNull(); - }); - - it("is collected once it is settled, and says so in the feed", async () => { +describe("history retention", () => { + it("neither hides nor deletes a settled group merely because time passes", async () => { const { groupId, ravi, priya } = await makeGroup(); const object = stub(groupId); - - const id = freshId("e"); - const entry = ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 1000), RAVI)); + const entry = ok(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id, priya.id], 1000), RAVI)); ok(await deleteEntry(object, entry, RAVI)); - - const outcome = await object.runUpkeep(Date.now() + 800 * DAYS); - expect(outcome.purged).toBe(true); - - /** - * The end of a group is a change with a sequence number, which means it - * travels. Deleting the rows and telling nobody would leave a phone that - * had synced the group holding it forever. - */ + expect(await object.runUpkeep(Date.now() + 800 * DAYS)).toEqual({ purged: false }); const page = ok(await object.changes(RAVI, 0, 500)); - expect(page.purgedAt).not.toBeNull(); - expect(page.group).toBeNull(); - expect(page.entries).toHaveLength(0); - expect(page.members).toHaveLength(0); - }); - - it("stops answering writes afterwards, and says which kind of nothing it is", async () => { - const { groupId, ravi, priya } = await makeGroup(); - const object = stub(groupId); - - const id = freshId("e"); - const entry = ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 500), RAVI)); - ok(await deleteEntry(object, entry, RAVI)); - await object.runUpkeep(Date.now() + 800 * DAYS); - - expect(refusal(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id], 100), RAVI)).code).toBe("group_purged"); - }); - - it("takes its rows out of the membership index with it", async () => { - const { groupId, ravi, priya } = await makeGroup(); - const object = stub(groupId); - + expect(page.purgedAt).toBeNull(); + expect(page.entries).toHaveLength(1); expect(await membershipsIn(groupId)).toHaveLength(1); - - const id = freshId("e"); - const entry = ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 500), RAVI)); - ok(await deleteEntry(object, entry, RAVI)); - await object.runUpkeep(Date.now() + 800 * DAYS); - - expect(await membershipsIn(groupId)).toHaveLength(0); }); }); diff --git a/server/test/fx.test.ts b/server/test/fx.test.ts index 3f0a217..cacf4e5 100644 --- a/server/test/fx.test.ts +++ b/server/test/fx.test.ts @@ -1,15 +1,13 @@ import { env, exports as workerExports } from "cloudflare:workers"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - import { monthsFrom } from "../src/api/reference"; import { type MonthBlob, monthKey } from "../src/fx/blob"; import { reference, referenceEtag, supportedCurrencies } from "../src/reference"; import type { FxPage, Reference } from "../src/schemas/reference"; +import { apiUrl } from "./session"; /** Rates, and the object that is their only writer. */ -const ORIGIN = "https://opensplit.test"; - /** A `fetch` that answers from a script, and records what it was asked. */ function stubFetch(script: (url: string) => unknown | null) { const calls: string[] = []; @@ -328,7 +326,7 @@ describe("monthsFrom", () => { describe("the reference route", () => { it("serves the bundled data, and a 304 for an unchanged ETag", async () => { - const response = await workerExports.default.fetch(`${ORIGIN}/api/reference`); + const response = await workerExports.default.fetch(apiUrl("/reference")); expect(response.status).toBe(200); const body = (await response.json()) as Reference; @@ -338,17 +336,17 @@ describe("the reference route", () => { const etag = response.headers.get("ETag"); expect(etag).toBe(referenceEtag); - const again = await workerExports.default.fetch(`${ORIGIN}/api/reference`, { headers: { "If-None-Match": etag ?? "" } }); + const again = await workerExports.default.fetch(apiUrl("/reference"), { headers: { "If-None-Match": etag ?? "" } }); expect(again.status).toBe(304); }); it("needs no session, because it is the same for everybody", async () => { // `using (true)` in SQL said the same thing at more length. - expect((await workerExports.default.fetch(`${ORIGIN}/api/reference`)).status).toBe(200); + expect((await workerExports.default.fetch(apiUrl("/reference"))).status).toBe(200); }); it("carries an exponent for every currency, because amounts are integers", async () => { - const body = (await workerExports.default.fetch(`${ORIGIN}/api/reference`).then((r) => r.json())) as Reference; + const body = (await workerExports.default.fetch(apiUrl("/reference")).then((r) => r.json())) as Reference; // Getting one wrong is a factor-of-a-thousand error in somebody's balance // rather than a formatting quirk: JPY and KRW have no minor unit at all, @@ -368,7 +366,7 @@ describe("the fx route", () => { }); it("reads from KV rather than waking the object", async () => { - const response = await workerExports.default.fetch(`${ORIGIN}/api/fx?since=2026-09-01`); + const response = await workerExports.default.fetch(apiUrl("/fx?since=2026-09-01")); expect(response.status).toBe(200); const page = (await response.json()) as FxPage; @@ -382,12 +380,12 @@ describe("the fx route", () => { }); it("refuses something that is not a date", async () => { - const response = await workerExports.default.fetch(`${ORIGIN}/api/fx?since=last-tuesday`); + const response = await workerExports.default.fetch(apiUrl("/fx?since=last-tuesday")); expect(response.status).toBe(400); }); it("answers an empty page rather than nothing for a month never published", async () => { - const page = (await workerExports.default.fetch(`${ORIGIN}/api/fx?since=2026-09-30`).then((r) => r.json())) as FxPage; + const page = (await workerExports.default.fetch(apiUrl("/fx?since=2026-09-30")).then((r) => r.json())) as FxPage; expect(page.rates).toEqual([]); }); @@ -395,7 +393,7 @@ describe("the fx route", () => { // A device asking from six years ago must not get 2020 and an empty page: // its high-water mark would stay six years behind and every sync would // fetch the same nothing. - const response = await workerExports.default.fetch(`${ORIGIN}/api/fx?since=2020-01-01`); + const response = await workerExports.default.fetch(apiUrl("/fx?since=2020-01-01")); const page = (await response.json()) as FxPage; expect(page.hasMore).toBe(true); diff --git a/server/test/group.ts b/server/test/group.ts index 107c522..f3b4f64 100644 --- a/server/test/group.ts +++ b/server/test/group.ts @@ -66,7 +66,7 @@ export async function makeGroup(options: { groupId?: string; name?: string; curr defaultCurrency: options.currency ?? "INR", isDirect: false, simplifyDebts: true, - archivedAt: null, + baseSeq: null, ...defaultGroupLook, creatorId: `${groupId}-ravi`, creatorName: "Ravi", @@ -75,7 +75,7 @@ export async function makeGroup(options: { groupId?: string; name?: string; curr ), ); - const priya = ok(await object.putMember(`${groupId}-priya`, { displayName: "Priya", upiVpa: null, leftAt: null }, options.owner ?? RAVI)); + const priya = ok(await object.putMember(`${groupId}-priya`, { displayName: "Priya", upiVpa: null, leftAt: null, baseSeq: null }, options.owner ?? RAVI)); const changes = ok(await object.changes(options.owner ?? RAVI, 0, 500)); const ravi = changes.members.find((member) => member.profileId === (options.owner ?? RAVI)); @@ -161,9 +161,9 @@ export async function editGroup(groupId: string, profileId: string, changes: Par const object = stub(groupId); const group = ok(await object.changes(profileId, 0, 500)).group; if (!group) expect.unreachable("No group row to edit."); - const { name, defaultCurrency, isDirect, simplifyDebts, archivedAt, avatarKind, avatarColor, avatarEmoji, avatarIcon, avatarPhoto, coverKind, coverPhoto, createdBy } = group; + const { name, defaultCurrency, isDirect, simplifyDebts, avatarKind, avatarColor, avatarEmoji, avatarIcon, avatarPhoto, coverKind, coverPhoto, createdBy } = group; const look = { avatarKind, avatarColor, avatarEmoji, avatarIcon, avatarPhoto, coverKind, coverPhoto }; - return object.putGroup(groupId, { name, defaultCurrency, isDirect, simplifyDebts, archivedAt, ...look, creatorId: createdBy, creatorName: "Ravi", ...changes }, profileId); + return object.putGroup(groupId, { name, defaultCurrency, isDirect, simplifyDebts, baseSeq: group.seq, ...look, creatorId: createdBy, creatorName: "Ravi", ...changes }, profileId); } /** The same for a member row. */ @@ -171,7 +171,7 @@ export async function editMember(groupId: string, memberId: string, profileId: s const object = stub(groupId); const member = await memberRow(groupId, memberId, profileId); const { displayName, upiVpa, leftAt } = member; - return object.putMember(memberId, { displayName, upiVpa, leftAt, ...changes }, profileId); + return object.putMember(memberId, { displayName, upiVpa, leftAt, baseSeq: member.seq, ...changes }, profileId); } /** A member row as the group object currently holds it. Read as `RAVI`, who is in every fixture. */ diff --git a/server/test/handover.test.ts b/server/test/handover.test.ts index 61e5f8e..6fb04e3 100644 --- a/server/test/handover.test.ts +++ b/server/test/handover.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import type { IdentityOutcome } from "../src/schemas/identity"; import { googleIdToken } from "./google"; import { BY_INVITE, makeGroup, ok, RAVI, stub } from "./group"; -import { signInAsGuest } from "./session"; +import { apiUrl, signInAsGuest } from "./session"; /** * A guest signing in to an account they already had. Both are the same @@ -13,7 +13,7 @@ import { signInAsGuest } from "./session"; */ async function continueWithGoogle(sub: string, email: string, token?: string): Promise { - const response = await workerExports.default.fetch("https://opensplit.test/api/identity/google", { + const response = await workerExports.default.fetch(apiUrl("/identity/google"), { method: "POST", headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify({ idToken: await googleIdToken({ sub, email }), nonce: null, allowSignIn: true }), @@ -25,7 +25,7 @@ async function continueWithGoogle(sub: string, email: string, token?: string): P /** Ravi's group, with a place the guest has claimed. */ async function groupWithGuest(guestId: string, placeName = "Priya") { const { groupId } = await makeGroup(); - const place = ok(await stub(groupId).putMember(`${groupId}-${placeName}`, { displayName: placeName, upiVpa: null, leftAt: null }, RAVI)); + const place = ok(await stub(groupId).putMember(`${groupId}-${placeName}`, { displayName: placeName, upiVpa: null, leftAt: null, baseSeq: null }, RAVI)); const invite = ok(await stub(groupId).createInvite(place.id, RAVI)); ok(await stub(groupId).join(invite.token, guestId, BY_INVITE)); return { groupId, placeId: place.id }; @@ -69,7 +69,7 @@ describe("a guest signing in to an account they already had", () => { const guest = await signInAsGuest(); const { groupId, placeId } = await groupWithGuest(guest.id, "Guest Priya"); - const ownersPlace = ok(await stub(groupId).putMember(`${groupId}-owner`, { displayName: "Priya", upiVpa: null, leftAt: null }, RAVI)); + const ownersPlace = ok(await stub(groupId).putMember(`${groupId}-owner`, { displayName: "Priya", upiVpa: null, leftAt: null, baseSeq: null }, RAVI)); const invite = ok(await stub(groupId).createInvite(ownersPlace.id, RAVI)); ok(await stub(groupId).join(invite.token, owner.account.id, BY_INVITE)); diff --git a/server/test/hardening.test.ts b/server/test/hardening.test.ts index 394bb0a..7f4d032 100644 --- a/server/test/hardening.test.ts +++ b/server/test/hardening.test.ts @@ -2,14 +2,12 @@ import { env, exports as workerExports } from "cloudflare:workers"; import { describe, expect, it } from "vitest"; import { defaultAvatar, defaultGroupLook } from "../src/db/appearance"; -import { randomIp, signInAsGuest } from "./session"; +import { apiUrl, randomIp, signInAsGuest } from "./session"; /** The brakes on the doors anybody can knock on, and the refusals that must say the right thing. */ -const ORIGIN = "https://opensplit.test"; - function post(path: string, body: unknown, headers: Record = {}) { - return workerExports.default.fetch(`${ORIGIN}/api${path}`, { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify(body) }); + return workerExports.default.fetch(apiUrl(path), { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify(body) }); } async function errorOf(response: Response) { @@ -93,9 +91,9 @@ describe("an expense with half an exchange rate", () => { const guest = await signInAsGuest(); const groupId = crypto.randomUUID(); const auth = { Authorization: `Bearer ${guest.token}` }; - const put = (path: string, body: unknown) => workerExports.default.fetch(`${ORIGIN}/api${path}`, { method: "PUT", headers: { "Content-Type": "application/json", ...auth }, body: JSON.stringify(body) }); + const put = (path: string, body: unknown) => workerExports.default.fetch(apiUrl(path), { method: "PUT", headers: { "Content-Type": "application/json", ...auth }, body: JSON.stringify(body) }); - expect((await put(`/groups/${groupId}`, { name: "Trip", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: null, ...defaultGroupLook, creatorId: "me", creatorName: "Me" })).status).toBe(200); + expect((await put(`/groups/${groupId}`, { name: "Trip", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, baseSeq: null, ...defaultGroupLook, creatorId: "me", creatorName: "Me" })).status).toBe(200); const response = await put(`/groups/${groupId}/entries/e1`, { kind: "expense", @@ -127,7 +125,7 @@ describe("a crash", () => { const guest = await signInAsGuest(); await env.DB.prepare("alter table profiles rename to profiles_away").run(); try { - const response = await workerExports.default.fetch(`${ORIGIN}/api/profile`, { + const response = await workerExports.default.fetch(apiUrl("/profile"), { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${guest.token}` }, body: JSON.stringify({ displayName: "Asha", upiVpa: null, ...defaultAvatar }), diff --git a/server/test/identity.test.ts b/server/test/identity.test.ts index 475fe1e..9ecaeed 100644 --- a/server/test/identity.test.ts +++ b/server/test/identity.test.ts @@ -1,7 +1,8 @@ import { env, exports as workerExports } from "cloudflare:workers"; import { describe, expect, it } from "vitest"; +import { authBasePath } from "../src/paths"; import { googleIdToken } from "./google"; -import { signInAsGuest } from "./session"; +import { apiUrl, signInAsGuest } from "./session"; /** The branches that decide whether somebody's ledger survives. */ @@ -20,7 +21,7 @@ function everyOutcomeCarriesTheField(body: Outcome) { } async function continueWithGoogle(idToken: string, options: { token?: string; allowSignIn?: boolean; nonce?: string } = {}): Promise { - return workerExports.default.fetch("https://opensplit.test/api/identity/google", { + return workerExports.default.fetch(apiUrl("/identity/google"), { method: "POST", headers: { "Content-Type": "application/json", @@ -165,7 +166,7 @@ describe("continuing with Google", () => { describe("the email code flow", () => { async function startEmail(email: string, token?: string): Promise<{ flow: string }> { - const response = await workerExports.default.fetch("https://opensplit.test/api/identity/email", { + const response = await workerExports.default.fetch(apiUrl("/identity/email"), { method: "POST", headers: { "Content-Type": "application/json", @@ -211,7 +212,7 @@ describe("the email code flow", () => { it("refuses a wrong code as a refusal, not a crash", async () => { await startEmail("wrong-code@example.com"); - const response = await workerExports.default.fetch("https://opensplit.test/api/identity/email/verify", { + const response = await workerExports.default.fetch(apiUrl("/identity/email/verify"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "wrong-code@example.com", code: "00000000", flow: "signInPending" }), @@ -222,7 +223,7 @@ describe("the email code flow", () => { }); it("refuses a malformed address with the standard error shape", async () => { - const response = await workerExports.default.fetch("https://opensplit.test/api/identity/email", { + const response = await workerExports.default.fetch(apiUrl("/identity/email"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "not-an-address" }), @@ -236,7 +237,7 @@ describe("the email code flow", () => { }); describe("the session", () => { - const session = (token?: string) => workerExports.default.fetch("https://opensplit.test/api/identity/session", { headers: token ? { Authorization: `Bearer ${token}` } : {} }); + const session = (token?: string) => workerExports.default.fetch(apiUrl("/identity/session"), { headers: token ? { Authorization: `Bearer ${token}` } : {} }); it("is none without a credential", async () => { expect(await (await session()).json()).toEqual({ account: null, token: null }); @@ -250,7 +251,7 @@ describe("the session", () => { it("ends on sign-out", async () => { const guest = await signInAsGuest(); - const out = await workerExports.default.fetch("https://opensplit.test/api/identity/sign-out", { method: "POST", headers: { Authorization: `Bearer ${guest.token}` } }); + const out = await workerExports.default.fetch(apiUrl("/identity/sign-out"), { method: "POST", headers: { Authorization: `Bearer ${guest.token}` } }); expect(out.status).toBe(204); expect(await (await session(guest.token)).json()).toEqual({ account: null, token: null }); @@ -259,7 +260,7 @@ describe("the session", () => { describe("the browser flow to Google", () => { it("answers with a Google URL and the state cookie the callback will check", async () => { - const response = await workerExports.default.fetch("https://opensplit.test/api/identity/google/redirect", { + const response = await workerExports.default.fetch(apiUrl("/identity/google/redirect"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ callbackUrl: "http://localhost:8787/app/welcome", allowSignIn: false }), @@ -268,6 +269,7 @@ describe("the browser flow to Google", () => { expect(response.status).toBe(200); const { url } = (await response.json()) as { url: string }; expect(new URL(url).hostname).toBe("accounts.google.com"); + expect(new URL(url).searchParams.get("redirect_uri")).toBe(`${env.APP_ORIGIN}${authBasePath}/callback/google`); expect(response.headers.getSetCookie().length).toBeGreaterThan(0); }); }); diff --git a/server/test/index.test.ts b/server/test/index.test.ts index f1a24ce..6a25560 100644 --- a/server/test/index.test.ts +++ b/server/test/index.test.ts @@ -45,7 +45,7 @@ describe("when D1 is not answering", () => { const groupId = freshId("outbox"); await withTableAway("memberships", async () => { - ok(await stub(groupId).putGroup(groupId, { name: "Offline", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: null, ...defaultGroupLook, creatorId: `${groupId}-m`, creatorName: "Ravi" }, RAVI)); + ok(await stub(groupId).putGroup(groupId, { name: "Offline", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, baseSeq: null, ...defaultGroupLook, creatorId: `${groupId}-m`, creatorName: "Ravi" }, RAVI)); // The group itself is fine. The index simply does not know about it yet. const waiting = await outboxOf(groupId); @@ -58,18 +58,18 @@ describe("when D1 is not answering", () => { }); /** The retry deadline outliving its retry used to leave the group with no alarm at all. */ - it("drops the retry once it succeeds, and the dormancy clock is armed again", async () => { + it("drops the retry and alarm once the index catches up", async () => { const groupId = freshId("outbox"); await withTableAway("memberships", async () => { - ok(await stub(groupId).putGroup(groupId, { name: "Offline", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: null, ...defaultGroupLook, creatorId: `${groupId}-m`, creatorName: "Ravi" }, RAVI)); + ok(await stub(groupId).putGroup(groupId, { name: "Offline", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, baseSeq: null, ...defaultGroupLook, creatorId: `${groupId}-m`, creatorName: "Ravi" }, RAVI)); }); await stub(groupId).runUpkeep(Date.now()); const after = await outboxOf(groupId); expect(after.pending).toBe(0); - expect(after.chores).toEqual(["archive"]); - expect(after.alarm ?? 0).toBeGreaterThan(Date.now() + 80 * DAYS); + expect(after.chores).toEqual([]); + expect(after.alarm).toBeNull(); }); it("sends a backlog of any size in one go, not twenty at a time", async () => { @@ -78,7 +78,7 @@ describe("when D1 is not answering", () => { await withTableAway("link_tokens", async () => { for (let index = 0; index < 25; index++) { - const member = ok(await object.putMember(`${groupId}-p${index}`, { displayName: `P${index}`, upiVpa: null, leftAt: null }, RAVI)); + const member = ok(await object.putMember(`${groupId}-p${index}`, { displayName: `P${index}`, upiVpa: null, leftAt: null, baseSeq: null }, RAVI)); ok(await object.createInvite(member.id, RAVI)); } expect((await outboxOf(groupId)).pending).toBe(25); @@ -110,11 +110,11 @@ describe("the order writes arrive in", () => { const priya = await makeAccount("Priya"); const { groupId, priya: place } = await makeGroup(); const invite = ok(await stub(groupId).createInvite(place.id, RAVI)); - ok(await stub(groupId).join(invite.token, priya, BY_INVITE)); + const joined = ok(await stub(groupId).join(invite.token, priya, BY_INVITE)); // Leaving and being brought back, many times over, all in flight at once. const at = new Date().toISOString(); - await Promise.all(Array.from({ length: 12 }, (_, index) => (index % 2 === 0 ? stub(groupId).putMember(place.id, { displayName: "Priya", upiVpa: null, leftAt: at }, priya) : stub(groupId).putMember(place.id, { displayName: "Priya", upiVpa: null, leftAt: null }, RAVI)))); + await Promise.all(Array.from({ length: 12 }, (_, index) => (index % 2 === 0 ? stub(groupId).putMember(place.id, { displayName: "Priya", upiVpa: null, leftAt: at, baseSeq: joined.member.seq }, priya) : stub(groupId).putMember(place.id, { displayName: "Priya", upiVpa: null, leftAt: null, baseSeq: null }, RAVI)))); const truth = ok(await stub(groupId).changes(RAVI, 0, 500)).members.find((member) => member.id === place.id); expect((await membership(groupId, priya))?.left_at ?? null).toBe(truth?.leftAt ?? null); @@ -145,7 +145,7 @@ describe("an account deleted while its membership is on the way", () => { const groupId = freshId("gone"); await withTableAway("memberships", async () => { - ok(await stub(groupId).putGroup(groupId, { name: "Solo", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: null, ...defaultGroupLook, creatorId: `${groupId}-m`, creatorName: "Asha" }, owner)); + ok(await stub(groupId).putGroup(groupId, { name: "Solo", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, baseSeq: null, ...defaultGroupLook, creatorId: `${groupId}-m`, creatorName: "Asha" }, owner)); await env.DB.prepare("delete from user where id = ?").bind(owner).run(); }); await stub(groupId).runUpkeep(Date.now()); diff --git a/server/test/invites.test.ts b/server/test/invites.test.ts index 034f2e6..3c3a704 100644 --- a/server/test/invites.test.ts +++ b/server/test/invites.test.ts @@ -15,7 +15,7 @@ describe("a stranger holding no token", () => { expect(refusal(await stub(groupId).changes(ZARA, 0, 100)).code).toBe("not_member"); expect(refusal(await stub(groupId).createInvite(priya.id, ZARA)).code).toBe("not_member"); expect(refusal(await stub(groupId).createLink(ZARA)).code).toBe("not_member"); - expect(refusal(await stub(groupId).putGroup(groupId, { name: "Zara's now", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: null, ...defaultGroupLook, creatorId: freshId("m"), creatorName: "Zara" }, ZARA)).code).toBe("not_member"); + expect(refusal(await stub(groupId).putGroup(groupId, { name: "Zara's now", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, baseSeq: null, ...defaultGroupLook, creatorId: freshId("m"), creatorName: "Zara" }, ZARA)).code).toBe("not_member"); }); }); diff --git a/server/test/ledger.test.ts b/server/test/ledger.test.ts index 63ab0a6..066a5a9 100644 --- a/server/test/ledger.test.ts +++ b/server/test/ledger.test.ts @@ -468,7 +468,7 @@ describe("an expense shared by a crowd", () => { const object = stub(groupId); const people = [ravi.id]; for (let index = 0; index < 39; index++) { - people.push(ok(await object.putMember(freshId("m"), { displayName: `Guest ${index}`, upiVpa: null, leftAt: null }, RAVI)).id); + people.push(ok(await object.putMember(freshId("m"), { displayName: `Guest ${index}`, upiVpa: null, leftAt: null, baseSeq: null }, RAVI)).id); } const wedding = expense({ diff --git a/server/test/lifecycle.test.ts b/server/test/lifecycle.test.ts new file mode 100644 index 0000000..dad64a8 --- /dev/null +++ b/server/test/lifecycle.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { BY_INVITE, deleteEntry, draftOf, editGroup, editMember, evenly, freshId, makeAccount, makeGroup, makeGroupOfTwo, ok, PRIYA, RAVI, refusal, saveEntry, stub } from "./group"; + +const now = () => new Date().toISOString(); + +describe("leaving a group", () => { + it("deletes the last account's settled group, including deleted expenses and join links", async () => { + const { groupId, ravi } = await makeGroup(); + const object = stub(groupId); + const entry = ok(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id], 100), RAVI)); + ok(await deleteEntry(object, entry, RAVI)); + const link = ok(await object.createLink(RAVI)); + const input = { displayName: ravi.displayName, upiVpa: ravi.upiVpa, leftAt: now(), baseSeq: ravi.seq }; + expect(ok(await object.putMember(ravi.id, input, RAVI)).leftAt).not.toBeNull(); + // A lost reply is resolved by the tombstone; it must never recreate the group. + expect(refusal(await object.putMember(ravi.id, input, RAVI)).code).toBe("group_purged"); + const page = ok(await object.changes(RAVI, 0, 500)); + expect(page.purgedAt).not.toBeNull(); + expect(page.group).toBeNull(); + expect(page.entries).toEqual([]); + expect(page.members).toEqual([]); + expect(refusal(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id], 100), RAVI)).code).toBe("group_purged"); + expect(refusal(await object.join(link.token, PRIYA, { memberId: null, displayName: "Priya" })).code).toBe("group_purged"); + }); + + it("does not strand debts between placeholders when the last account tries to leave", async () => { + const { groupId, ravi, priya } = await makeGroup(); + const object = stub(groupId); + const arun = ok(await object.putMember(freshId("m"), { displayName: "Arun", upiVpa: null, leftAt: null, baseSeq: null }, RAVI)); + ok(await saveEntry(object, evenly(freshId("e"), priya.id, [priya.id, arun.id], 100), RAVI)); + expect(refusal(await editMember(groupId, ravi.id, RAVI, { leftAt: now() })).code).toBe("not_settled"); + expect(ok(await object.changes(RAVI, 0, 500)).group?.id).toBe(groupId); + }); + + it("ends access, retains shared records, and restores current access only through a valid link", async () => { + const { groupId, ravi } = await makeGroupOfTwo(); + const object = stub(groupId); + const entry = ok(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id], 200), RAVI)); + const input = { displayName: ravi.displayName, upiVpa: ravi.upiVpa, leftAt: now(), baseSeq: ravi.seq }; + const left = ok(await object.putMember(ravi.id, input, RAVI)); + expect(ok(await object.putMember(ravi.id, input, RAVI))).toEqual(left); + expect(refusal(await object.changes(RAVI, 0, 500)).code).toBe("not_member"); + expect(refusal(await object.createLink(RAVI)).code).toBe("not_member"); + expect(ok(await object.changes(PRIYA, 0, 500)).entries).toContainEqual(entry); + ok(await editGroup(groupId, PRIYA, { name: "Current name" })); + ok(await saveEntry(object, draftOf(entry, { description: "Current expense" }), PRIYA)); + expect(refusal(await object.changes(RAVI, 0, 1)).code).toBe("not_member"); + const link = ok(await object.createLink(PRIYA)); + const joined = ok(await object.join(link.token, RAVI, { memberId: null, displayName: "Ravi" })); + expect(joined.member.id).toBe(ravi.id); + expect(joined.member.leftAt).toBeNull(); + const current = ok(await object.changes(RAVI, 0, 500)); + expect(current.group?.name).toBe("Current name"); + expect(current.entries[0]?.description).toBe("Current expense"); + }); + + it("refuses stale member and group replacements without undoing newer settings", async () => { + const { groupId, priya, group } = await makeGroup(); + ok(await editGroup(groupId, RAVI, { name: "Latest name", baseSeq: group.seq })); + expect(refusal(await editGroup(groupId, RAVI, { name: "Stale name", baseSeq: group.seq })).code).toBe("stale_base"); + ok(await editMember(groupId, priya.id, RAVI, { displayName: "New name", baseSeq: priya.seq })); + expect(refusal(await editMember(groupId, priya.id, RAVI, { displayName: "Old name", baseSeq: priya.seq })).code).toBe("stale_base"); + }); +}); + +describe("account ownership", () => { + it("does not give former guests access when transferring to an account", async () => { + const { groupId, ravi } = await makeGroupOfTwo(); + const object = stub(groupId); + ok(await editMember(groupId, ravi.id, RAVI, { leftAt: now() })); + const heir = await makeAccount("Ravi's saved account"); + expect(await object.handOver(RAVI, heir)).toEqual({ forgotten: true, purged: false }); + expect(refusal(await object.changes(heir, 0, 500)).code).toBe("not_member"); + expect(refusal(await object.changes(RAVI, 0, 500)).code).toBe("not_member"); + expect(await object.forgetProfile(PRIYA, "Priya")).toEqual({ forgotten: true, purged: true }); + }); + + it("transfers an active guest's access to an existing account that previously left", async () => { + const { groupId, ravi, priya } = await makeGroupOfTwo(); + const object = stub(groupId); + ok(await editMember(groupId, ravi.id, RAVI, { leftAt: now() })); + const entry = ok(await saveEntry(object, evenly(freshId("e"), priya.id, [priya.id], 200), PRIYA)); + expect(await object.handOver(PRIYA, RAVI)).toEqual({ forgotten: true, purged: false }); + const page = ok(await object.changes(RAVI, 0, 500)); + expect(page.members.find((member) => member.id === ravi.id)?.leftAt).toBeNull(); + expect(page.members.find((member) => member.id === priya.id)?.profileId).toBeNull(); + expect(page.entries).toContainEqual(entry); + }); + + it("deletes the group when the last active account is deleted even if former members remain", async () => { + const { groupId, ravi } = await makeGroupOfTwo(); + const object = stub(groupId); + ok(await editMember(groupId, ravi.id, RAVI, { leftAt: now() })); + expect(await object.forgetProfile(PRIYA, "Priya Last Name")).toEqual({ forgotten: true, purged: true }); + expect(ok(await object.changes(RAVI, 0, 500)).purgedAt).not.toBeNull(); + }); + + it("leaves an ordinary active placeholder that can receive expenses and be claimed", async () => { + const { groupId, ravi, priya } = await makeGroupOfTwo(); + const object = stub(groupId); + await object.forgetProfile(PRIYA, "Priya Last Name"); + const member = ok(await object.changes(RAVI, 0, 500)).members.find((row) => row.id === priya.id); + expect(member).toMatchObject({ profileId: null, leftAt: null, displayName: "Priya Last Name" }); + ok(await saveEntry(object, evenly(freshId("e"), priya.id, [ravi.id, priya.id], 100), RAVI)); + const heir = await makeAccount("Another account"); + const invite = ok(await object.createInvite(priya.id, RAVI)); + expect(ok(await object.join(invite.token, heir, BY_INVITE)).member.id).toBe(priya.id); + }); +}); diff --git a/server/test/links.test.ts b/server/test/links.test.ts index 144ef88..7efd7be 100644 --- a/server/test/links.test.ts +++ b/server/test/links.test.ts @@ -5,18 +5,16 @@ import { defaultAvatar, defaultGroupLook } from "../src/db/appearance"; import type { ApiError } from "../src/schemas/common"; import type { GroupLink, Invite, Joined, LinkPreview, LinkRevocation, LiveLink, PlaceholderList } from "./api-types"; import { freshId } from "./group"; -import { changes, type Guest, signInAsGuest } from "./session"; +import { apiUrl, changes, type Guest, signInAsGuest } from "./session"; /** Arriving, over HTTP. */ -const ORIGIN = "https://opensplit.test"; - async function call(path: string, guest: Guest | null, init: RequestInit = {}) { const headers = new Headers(init.headers); headers.set("Content-Type", "application/json"); if (guest) headers.set("Authorization", `Bearer ${guest.token}`); - return workerExports.default.fetch(`${ORIGIN}${path}`, { ...init, headers }); + return workerExports.default.fetch(apiUrl(path), { ...init, headers }); } async function json(response: Response): Promise { @@ -28,16 +26,16 @@ async function makeGroup(host: Guest, placeholder = "Priya") { const id = freshId("link"); const memberId = `${id}-host`; - const created = await call(`/api/groups/${id}`, host, { + const created = await call(`/groups/${id}`, host, { method: "PUT", - body: JSON.stringify({ name: "Goa trip", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: null, ...defaultGroupLook, creatorId: memberId, creatorName: "Ravi" }), + body: JSON.stringify({ name: "Goa trip", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, baseSeq: null, ...defaultGroupLook, creatorId: memberId, creatorName: "Ravi" }), }); expect(created.status).toBe(200); const slotId = `${id}-slot`; - const slot = await call(`/api/groups/${id}/members/${slotId}`, host, { + const slot = await call(`/groups/${id}/members/${slotId}`, host, { method: "PUT", - body: JSON.stringify({ displayName: placeholder, upiVpa: null, leftAt: null }), + body: JSON.stringify({ displayName: placeholder, upiVpa: null, leftAt: null, baseSeq: null }), }); expect(slot.status).toBe(200); @@ -56,11 +54,11 @@ describe("an invite", () => { it("is previewable by somebody with no session, and then spendable", async () => { const { id, slotId } = await makeGroup(ravi); - const minted = await json(await call(`/api/groups/${id}/members/${slotId}/invite`, ravi, { method: "POST" })); + const minted = await json(await call(`/groups/${id}/members/${slotId}/invite`, ravi, { method: "POST" })); expect(minted.memberId).toBe(slotId); // No session. - const preview = await call(`/api/links/${minted.token}`, null); + const preview = await call(`/links/${minted.token}`, null); expect(preview.status).toBe(200); const seen = await json(preview); @@ -77,7 +75,7 @@ describe("an invite", () => { isMember: false, }); - const joined = await json(await call(`/api/links/${minted.token}/join`, priya, { method: "POST", body: JSON.stringify({ memberId: null, displayName: null }) })); + const joined = await json(await call(`/links/${minted.token}/join`, priya, { method: "POST", body: JSON.stringify({ memberId: null, displayName: null }) })); expect(joined.member.id).toBe(slotId); expect(joined.member.profileId).toBe(priya.id); @@ -89,33 +87,33 @@ describe("an invite", () => { it("says it is spent rather than saying nothing", async () => { const { id, slotId } = await makeGroup(ravi); - const minted = await json(await call(`/api/groups/${id}/members/${slotId}/invite`, ravi, { method: "POST" })); + const minted = await json(await call(`/groups/${id}/members/${slotId}/invite`, ravi, { method: "POST" })); - expect((await call(`/api/links/${minted.token}/join`, priya, { method: "POST", body: JSON.stringify({ memberId: null, displayName: null }) })).status).toBe(200); + expect((await call(`/links/${minted.token}/join`, priya, { method: "POST", body: JSON.stringify({ memberId: null, displayName: null }) })).status).toBe(200); const again = await signInAsGuest(); - const refused = await call(`/api/links/${minted.token}/join`, again, { method: "POST", body: JSON.stringify({ memberId: null, displayName: null }) }); + const refused = await call(`/links/${minted.token}/join`, again, { method: "POST", body: JSON.stringify({ memberId: null, displayName: null }) }); expect(refused.status).toBe(409); expect((await json(refused)).error.code).toBe("invite_spent"); // And the preview still describes it, so the screen can say which of the // three reasons it is rather than "invalid link". - const preview = await json(await call(`/api/links/${minted.token}`, null)); + const preview = await json(await call(`/links/${minted.token}`, null)); expect(preview.isRedeemed).toBe(true); }); it("stops routing once a newer one replaces it", async () => { const { id, slotId } = await makeGroup(ravi); - const first = await json(await call(`/api/groups/${id}/members/${slotId}/invite`, ravi, { method: "POST" })); - const second = await json(await call(`/api/groups/${id}/members/${slotId}/invite`, ravi, { method: "POST" })); + const first = await json(await call(`/groups/${id}/members/${slotId}/invite`, ravi, { method: "POST" })); + const second = await json(await call(`/groups/${id}/members/${slotId}/invite`, ravi, { method: "POST" })); // The index has to forget it, or a URL sitting in a chat history still // resolves to a group even though the object would refuse to spend it. - const stale = await call(`/api/links/${first.token}`, null); + const stale = await call(`/links/${first.token}`, null); expect(stale.status).toBe(404); - expect((await call(`/api/links/${second.token}`, null)).status).toBe(200); + expect((await call(`/links/${second.token}`, null)).status).toBe(200); }); }); @@ -123,20 +121,20 @@ describe("an open link", () => { it("is mintable, readable, and revocable by a member", async () => { const { id } = await makeGroup(ravi); - expect((await json(await call(`/api/groups/${id}/link`, ravi))).link).toBeNull(); + expect((await json(await call(`/groups/${id}/link`, ravi))).link).toBeNull(); - const minted = await json(await call(`/api/groups/${id}/link`, ravi, { method: "POST" })); + const minted = await json(await call(`/groups/${id}/link`, ravi, { method: "POST" })); - const live = await json(await call(`/api/groups/${id}/link`, ravi)); + const live = await json(await call(`/groups/${id}/link`, ravi)); expect(live.link?.token).toBe(minted.token); - const revoked = await json(await call(`/api/groups/${id}/link`, ravi, { method: "DELETE" })); + const revoked = await json(await call(`/groups/${id}/link`, ravi, { method: "DELETE" })); expect(revoked.revoked).toBe(minted.token); - expect((await json(await call(`/api/groups/${id}/link`, ravi))).link).toBeNull(); + expect((await json(await call(`/groups/${id}/link`, ravi))).link).toBeNull(); // Still describes itself, and says which kind of no this is. - const preview = await json(await call(`/api/links/${minted.token}`, null)); + const preview = await json(await call(`/links/${minted.token}`, null)); expect(preview.isRevoked).toBe(true); }); @@ -145,7 +143,7 @@ describe("an open link", () => { // The difference between reading a link and being handed one. for (const method of ["GET", "POST", "DELETE"]) { - const refused = await call(`/api/groups/${id}/link`, priya, { method }); + const refused = await call(`/groups/${id}/link`, priya, { method }); expect(refused.status, method).toBe(403); expect((await json(refused)).error.code).toBe("not_member"); } @@ -153,31 +151,31 @@ describe("an open link", () => { it("offers the places already typed in, to somebody who has signed in", async () => { const { id, slotId } = await makeGroup(ravi); - const minted = await json(await call(`/api/groups/${id}/link`, ravi, { method: "POST" })); + const minted = await json(await call(`/groups/${id}/link`, ravi, { method: "POST" })); // Names are more than the link implies to whoever holds it, so this one // needs a session where the preview does not. - const anonymous = await call(`/api/links/${minted.token}/placeholders`, null); + const anonymous = await call(`/links/${minted.token}/placeholders`, null); expect(anonymous.status).toBe(401); - const { placeholders } = await json(await call(`/api/links/${minted.token}/placeholders`, priya)); + const { placeholders } = await json(await call(`/links/${minted.token}/placeholders`, priya)); expect(placeholders).toEqual([{ id: slotId, displayName: "Priya" }]); // Claiming one is what stops a group of six becoming a group of twelve // when one link is pasted into a chat. - const joined = await json(await call(`/api/links/${minted.token}/join`, priya, { method: "POST", body: JSON.stringify({ memberId: slotId, displayName: null }) })); + const joined = await json(await call(`/links/${minted.token}/join`, priya, { method: "POST", body: JSON.stringify({ memberId: slotId, displayName: null }) })); expect(joined.member.id).toBe(slotId); - const after = await json(await call(`/api/links/${minted.token}/placeholders`, ravi)); + const after = await json(await call(`/links/${minted.token}/placeholders`, ravi)); expect(after.placeholders).toEqual([]); }); it("lets somebody nobody typed in arrive under their own name", async () => { const { id } = await makeGroup(ravi); - const minted = await json(await call(`/api/groups/${id}/link`, ravi, { method: "POST" })); + const minted = await json(await call(`/groups/${id}/link`, ravi, { method: "POST" })); const zara = await signInAsGuest(); - const joined = await json(await call(`/api/links/${minted.token}/join`, zara, { method: "POST", body: JSON.stringify({ memberId: null, displayName: "Zara" }) })); + const joined = await json(await call(`/links/${minted.token}/join`, zara, { method: "POST", body: JSON.stringify({ memberId: null, displayName: "Zara" }) })); expect(joined.member.displayName).toBe("Zara"); expect(joined.member.profileId).toBe(zara.id); @@ -186,21 +184,21 @@ describe("an open link", () => { // the object's rather than the schema's: `displayName` is legitimately null // when a placeholder is being claimed instead. const nameless = await signInAsGuest(); - const refused = await call(`/api/links/${minted.token}/join`, nameless, { method: "POST", body: JSON.stringify({ memberId: null, displayName: null }) }); + const refused = await call(`/links/${minted.token}/join`, nameless, { method: "POST", body: JSON.stringify({ memberId: null, displayName: null }) }); expect(refused.status).toBe(400); expect((await json(refused)).error.code).toBe("malformed"); }); it("uses the name already on the account when none is typed", async () => { const { id } = await makeGroup(ravi); - const minted = await json(await call(`/api/groups/${id}/link`, ravi, { method: "POST" })); + const minted = await json(await call(`/groups/${id}/link`, ravi, { method: "POST" })); // Anybody who signed in with Google or an email address has one, which is // most people arriving on a link. const known = await signInAsGuest(); - await call("/api/profile", known, { method: "PUT", body: JSON.stringify({ displayName: "Zara", upiVpa: null, ...defaultAvatar }) }); + await call("/profile", known, { method: "PUT", body: JSON.stringify({ displayName: "Zara", upiVpa: null, ...defaultAvatar }) }); - const joined = await json(await call(`/api/links/${minted.token}/join`, known, { method: "POST", body: JSON.stringify({ memberId: null, displayName: null }) })); + const joined = await json(await call(`/links/${minted.token}/join`, known, { method: "POST", body: JSON.stringify({ memberId: null, displayName: null }) })); expect(joined.member.displayName).toBe("Zara"); }); }); @@ -208,10 +206,10 @@ describe("an open link", () => { describe("the name, travelling the other way", () => { it("is adopted from the place a friend typed, when the account has none", async () => { const { id, slotId } = await makeGroup(ravi, "Priya"); - const minted = await json(await call(`/api/groups/${id}/members/${slotId}/invite`, ravi, { method: "POST" })); + const minted = await json(await call(`/groups/${id}/members/${slotId}/invite`, ravi, { method: "POST" })); const arriving = await signInAsGuest(); - await call(`/api/links/${minted.token}/join`, arriving, { method: "POST", body: JSON.stringify({ memberId: null, displayName: null }) }); + await call(`/links/${minted.token}/join`, arriving, { method: "POST", body: JSON.stringify({ memberId: null, displayName: null }) }); // Somebody arriving on an invite signed in as a guest a moment earlier and // has no name at all, while the group already knows them as whatever was @@ -222,11 +220,11 @@ describe("the name, travelling the other way", () => { it("never overwrites a name somebody chose with a friend's guess", async () => { const { id, slotId } = await makeGroup(ravi, "P"); - const minted = await json(await call(`/api/groups/${id}/members/${slotId}/invite`, ravi, { method: "POST" })); + const minted = await json(await call(`/groups/${id}/members/${slotId}/invite`, ravi, { method: "POST" })); const arriving = await signInAsGuest(); - await call("/api/profile", arriving, { method: "PUT", body: JSON.stringify({ displayName: "Priya Sharma", upiVpa: null, ...defaultAvatar }) }); - const claimed = await json(await call(`/api/links/${minted.token}/join`, arriving, { method: "POST", body: JSON.stringify({ memberId: null, displayName: null }) })); + await call("/profile", arriving, { method: "PUT", body: JSON.stringify({ displayName: "Priya Sharma", upiVpa: null, ...defaultAvatar }) }); + const claimed = await json(await call(`/links/${minted.token}/join`, arriving, { method: "POST", body: JSON.stringify({ memberId: null, displayName: null }) })); // The slot keeps the name the group knows, and the account keeps its own. expect(claimed.member.displayName).toBe("P"); @@ -237,12 +235,12 @@ describe("the name, travelling the other way", () => { it("tells somebody already inside that they are, rather than offering to let them in", async () => { const { id } = await makeGroup(ravi); - const minted = await json(await call(`/api/groups/${id}/link`, ravi, { method: "POST" })); + const minted = await json(await call(`/groups/${id}/link`, ravi, { method: "POST" })); - const preview = await json(await call(`/api/links/${minted.token}`, ravi)); + const preview = await json(await call(`/links/${minted.token}`, ravi)); expect(preview.isMember).toBe(true); - const refused = await call(`/api/links/${minted.token}/join`, ravi, { method: "POST", body: JSON.stringify({ memberId: null, displayName: "Ravi again" }) }); + const refused = await call(`/links/${minted.token}/join`, ravi, { method: "POST", body: JSON.stringify({ memberId: null, displayName: "Ravi again" }) }); expect(refused.status).toBe(409); expect((await json(refused)).error.code).toBe("already_member"); }); @@ -252,12 +250,12 @@ describe("a token that names nothing", () => { it("is a 404 on every route that takes one", async () => { const unknown = freshId("nope"); - expect((await call(`/api/links/${unknown}`, null)).status).toBe(404); - expect((await call(`/api/links/${unknown}/placeholders`, priya)).status).toBe(404); - expect((await call(`/api/links/${unknown}/join`, priya, { method: "POST", body: JSON.stringify({ memberId: null, displayName: null }) })).status).toBe(404); + expect((await call(`/links/${unknown}`, null)).status).toBe(404); + expect((await call(`/links/${unknown}/placeholders`, priya)).status).toBe(404); + expect((await call(`/links/${unknown}/join`, priya, { method: "POST", body: JSON.stringify({ memberId: null, displayName: null }) })).status).toBe(404); // The same answer a wrong token gets, deliberately: telling the two apart // turns the endpoint into an oracle for which tokens exist. - expect((await json(await call(`/api/links/${unknown}`, null))).error.code).toBe("invite_invalid"); + expect((await json(await call(`/links/${unknown}`, null))).error.code).toBe("invite_invalid"); }); }); diff --git a/server/test/object.test.ts b/server/test/object.test.ts index d771ffc..544b87a 100644 --- a/server/test/object.test.ts +++ b/server/test/object.test.ts @@ -9,8 +9,6 @@ import { kindOf, statusFor } from "../src/do/group/refusal"; import { refusalCodes } from "../src/schemas/common"; import { evenly, freshId, makeGroup, ok, RAVI, saveEntry, stub } from "./group"; -const DAYS = 24 * 60 * 60 * 1000; - /** The object as a Cloudflare object, rather than as a ledger: its migrations, its alarm, and the type its methods actually present across the RPC boundary. */ describe("the schema this object migrates itself to", () => { @@ -49,18 +47,16 @@ describe("the schema this object migrates itself to", () => { }); describe("the one alarm", () => { - it("is armed for the dormancy clock as soon as there is a group", async () => { + it("has no destructive dormancy alarm for an owned group", async () => { const { groupId } = await makeGroup(); await runInDurableObject(stub(groupId), async (_instance: Group, state) => { const at = await state.storage.getAlarm(); - expect(at).not.toBeNull(); - expect(at ?? 0).toBeGreaterThan(Date.now() + 80 * DAYS); - expect(at ?? 0).toBeLessThan(Date.now() + 100 * DAYS); + expect(at).toBeNull(); }); }); - it("is pushed out again by every expense, so a group in use is never put away", async () => { + it("does not create a retention deadline when an expense is added", async () => { const { groupId, ravi, priya } = await makeGroup(); const before = await runInDurableObject(stub(groupId), async (_instance: Group, state) => state.storage.getAlarm()); @@ -68,10 +64,11 @@ describe("the one alarm", () => { ok(await saveEntry(stub(groupId), evenly(freshId("e"), ravi.id, [ravi.id, priya.id], 300), RAVI)); const after = await runInDurableObject(stub(groupId), async (_instance: Group, state) => state.storage.getAlarm()); - expect(after ?? 0).toBeGreaterThan(before ?? 0); + expect(before).toBeNull(); + expect(after).toBeNull(); }); - it("finds nothing to do when it fires early, and simply re-arms", async () => { + it("clears a spent alarm when no index work remains", async () => { const { groupId } = await makeGroup(); await runInDurableObject(stub(groupId), async (_instance: Group, state) => { @@ -79,7 +76,7 @@ describe("the one alarm", () => { }); expect(await runDurableObjectAlarm(stub(groupId))).toBe(true); - expect(ok(await stub(groupId).changes(RAVI, 0, 100)).group?.archivedAt).toBeNull(); + expect(ok(await stub(groupId).changes(RAVI, 0, 100)).purgedAt).toBeNull(); }); }); diff --git a/server/test/push.test.ts b/server/test/push.test.ts index c30872d..63e541b 100644 --- a/server/test/push.test.ts +++ b/server/test/push.test.ts @@ -119,7 +119,7 @@ describe("recording an expense", () => { }); describe("what does not wake a device", () => { - it("a rename, an archive, or a link being minted", async () => { + it("a rename or a link being minted", async () => { const fixture = await makeGroupOfTwo(); await registerDevice(PRIYA, "device-priya"); @@ -127,8 +127,6 @@ describe("what does not wake a device", () => { const object = stub(fixture.groupId); ok(await editGroup(fixture.groupId, RAVI, { name: "Goa, again" })); - ok(await editGroup(fixture.groupId, RAVI, { archivedAt: new Date().toISOString() })); - ok(await editGroup(fixture.groupId, RAVI, { archivedAt: null })); ok(await object.createLink(RAVI)); await settled(); diff --git a/server/test/reauth.test.ts b/server/test/reauth.test.ts index ab7cdb8..ca72f36 100644 --- a/server/test/reauth.test.ts +++ b/server/test/reauth.test.ts @@ -3,17 +3,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { IdentityOutcome } from "../src/schemas/identity"; import { googleIdToken } from "./google"; -import { randomIp, signInAsGuest } from "./session"; +import { apiUrl, randomIp, signInAsGuest } from "./session"; /** * A session lasts a year, so holding one is not proof enough to delete the * account behind it: an account with an address confirms with a fresh code. */ -const ORIGIN = "https://opensplit.test"; - function call(path: string, token: string, init: { method: string; body?: unknown }) { - return workerExports.default.fetch(`${ORIGIN}/api${path}`, { + return workerExports.default.fetch(apiUrl(path), { method: init.method, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, "CF-Connecting-IP": randomIp() }, body: init.body === undefined ? undefined : JSON.stringify(init.body), @@ -22,7 +20,7 @@ function call(path: string, token: string, init: { method: string; body?: unknow async function googleAccount(sub: string): Promise<{ id: string; token: string; email: string }> { const email = `${sub}@example.com`; - const response = await workerExports.default.fetch(`${ORIGIN}/api/identity/google`, { + const response = await workerExports.default.fetch(apiUrl("/identity/google"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ idToken: await googleIdToken({ sub, email }), nonce: null, allowSignIn: true }), diff --git a/server/test/retirement.test.ts b/server/test/retirement.test.ts new file mode 100644 index 0000000..e8df00d --- /dev/null +++ b/server/test/retirement.test.ts @@ -0,0 +1,48 @@ +import { Hono } from "hono"; +import { describe, expect, it } from "vitest"; + +import { unavailableApi } from "../src/api/routing"; + +// The production fallback, with future current versions so retirement can be +// verified before the first version has actually been retired. +function apiAt(version: number) { + const app = new Hono<{ Bindings: Env }>(); + app.get(`/api/v${version}/health`, (c) => c.json({ ok: true })); + app.all("/api/*", (c) => unavailableApi(c, version)); + return app; +} + +describe("numbered API retirement", () => { + it.each([ + [2, "/api/v1"], + [2, "/api/v1/"], + [2, "/api/v1/groups/g/entries/e"], + [3, "/api/v1/sync"], + [3, "/api/v2/sync"], + [12, "/api/v10/groups/g"], + ])("v%i retires %s for every request method", async (version, path) => { + const app = apiAt(version); + for (const method of ["GET", "POST", "PUT", "DELETE"]) { + const response = await app.request(path, { method }); + expect(response.status).toBe(410); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(await response.json()).toMatchObject({ error: { code: "update_required", retry: "permanent" } }); + } + }); + + it.each(["/api/sync", "/api/auth/callback/google", "/api/v0/sync", "/api/v01/sync", "/api/v-1/sync", "/api/v1x/sync", "/api/v1.0/sync", "/api/V1/sync", "/api/v2", "/api/v2/missing-operation", "/api/v3/sync", "/api/v20/sync", "/api/v999/groups/g"])("v2 returns an ordinary 404 for %s", async (path) => { + const response = await apiAt(2).request(path); + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ error: { code: "not_found", retry: "permanent" } }); + }); + + it("does not retire any numbered API at the v1 baseline", async () => { + const app = apiAt(1); + for (const path of ["/api/sync", "/api/v0/sync", "/api/v1/missing-operation", "/api/v2/sync"]) { + expect((await app.request(path)).status).toBe(404); + } + const response = await app.request("/api/v1/health"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + }); +}); diff --git a/server/test/roster.test.ts b/server/test/roster.test.ts index 19da10b..0b75ef0 100644 --- a/server/test/roster.test.ts +++ b/server/test/roster.test.ts @@ -109,21 +109,15 @@ describe("leaving, and being removed", () => { * Neither of the obvious answers is right, and this is the third one. See * `changes.ts`. */ - it("leaves the removed member reading up to their own removal, and no further", async () => { + it("ends read access as soon as the member is removed", async () => { const { groupId, ravi, priya } = await makeGroupOfTwo(); const object = stub(groupId); const removed = ok(await editMember(groupId, priya.id, RAVI, { leftAt: new Date().toISOString() })); - const hers = ok(await object.changes(PRIYA, 0, 500)); - expect(hers.seq).toBe(removed.seq); - expect(hers.members.find((member) => member.id === priya.id)?.leftAt).not.toBeNull(); - + expect(refusal(await object.changes(PRIYA, 0, 500)).code).toBe("not_member"); ok(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id], 700), RAVI)); - - const after = ok(await object.changes(PRIYA, hers.seq, 500)); - expect(after.entries).toHaveLength(0); - expect(after.hasMore).toBe(false); + expect(refusal(await object.changes(PRIYA, removed.seq, 500)).code).toBe("not_member"); }); it("including what the group is called after they went", async () => { @@ -132,7 +126,7 @@ describe("leaving, and being removed", () => { ok(await editMember(groupId, priya.id, RAVI, { leftAt: new Date().toISOString() })); ok(await editGroup(groupId, RAVI, { name: "Somewhere new" })); - expect(ok(await object.changes(PRIYA, 0, 500)).group?.name).not.toBe("Somewhere new"); + expect(refusal(await object.changes(PRIYA, 0, 500)).code).toBe("not_member"); }); it("and stops them writing to it at all", async () => { @@ -186,7 +180,7 @@ describe("coming back", () => { const object = stub(groupId); ok(await editMember(groupId, priya.id, PRIYA, { leftAt: new Date().toISOString() })); - const zara = ok(await object.putMember(freshId("m"), { displayName: "Zara", upiVpa: null, leftAt: null }, RAVI)); + const zara = ok(await object.putMember(freshId("m"), { displayName: "Zara", upiVpa: null, leftAt: null, baseSeq: null }, RAVI)); const invite = ok(await object.createInvite(zara.id, RAVI)); expect(ok(await object.join(invite.token, PRIYA, { memberId: null, displayName: null })).member.id).toBe(priya.id); @@ -236,8 +230,8 @@ describe("the group itself", () => { /** * `id`, `createdAt` and `createdBy` are not fields on the patch, so they * cannot be sent. Were they — as they would be on a full-row upsert — this - * one call would re-identify a group, back-date it into the dormancy purge, - * and reassign its authorship. + * one call would re-identify a group, back-date its creation, and reassign + * its authorship. */ it("cannot be re-identified, back-dated, or have its authorship reassigned", async () => { const { groupId, group } = await makeGroupOfTwo(); @@ -250,7 +244,7 @@ describe("the group itself", () => { it("cannot be claimed at an id somebody else is already using", async () => { const { groupId } = await makeGroup(); - const refused = refusal(await stub(groupId).putGroup(groupId, { name: "Mine now", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: null, ...defaultGroupLook, creatorId: freshId("m"), creatorName: "Zara" }, ZARA)); + const refused = refusal(await stub(groupId).putGroup(groupId, { name: "Mine now", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, baseSeq: null, ...defaultGroupLook, creatorId: freshId("m"), creatorName: "Zara" }, ZARA)); // Somebody writing to an id that is taken is a stranger to that group. expect(refused.code).toBe("not_member"); @@ -258,7 +252,7 @@ describe("the group itself", () => { it("answers the account that made it idempotently, for a retry whose response was lost", async () => { const { groupId, group } = await makeGroup(); - const again = ok(await stub(groupId).putGroup(groupId, { name: "Goa trip", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: null, ...defaultGroupLook, creatorId: `${groupId}-ravi`, creatorName: "Ravi" }, RAVI)); + const again = ok(await stub(groupId).putGroup(groupId, { name: "Goa trip", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, baseSeq: null, ...defaultGroupLook, creatorId: `${groupId}-ravi`, creatorName: "Ravi" }, RAVI)); expect(again.seq).toBe(group.seq); }); @@ -267,7 +261,7 @@ describe("the group itself", () => { const { groupId } = await makeGroup(); await stub(groupId).forgetProfile(RAVI, null); - const refused = refusal(await stub(groupId).putGroup(groupId, { name: "Back again", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: null, ...defaultGroupLook, creatorId: freshId("m"), creatorName: "Ravi" }, RAVI)); + const refused = refusal(await stub(groupId).putGroup(groupId, { name: "Back again", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, baseSeq: null, ...defaultGroupLook, creatorId: freshId("m"), creatorName: "Ravi" }, RAVI)); expect(refused.code).toBe("group_purged"); }); }); diff --git a/server/test/session.ts b/server/test/session.ts index 70a94e7..58f5627 100644 --- a/server/test/session.ts +++ b/server/test/session.ts @@ -1,5 +1,6 @@ import { exports as workerExports } from "cloudflare:workers"; +import { apiBasePath } from "../src/paths"; import type { IdentityOutcome } from "../src/schemas/identity"; import type { ChangePage, GroupCursor, Pull } from "../src/schemas/ledger"; @@ -11,7 +12,7 @@ export interface Guest { export async function signInAsGuest(): Promise { // A client of its own, as each real guest is: the guest door is rate-limited per address. - const response = await workerExports.default.fetch("https://opensplit.test/api/identity/guest", { method: "POST", headers: { "CF-Connecting-IP": randomIp() } }); + const response = await workerExports.default.fetch(apiUrl("/identity/guest"), { method: "POST", headers: { "CF-Connecting-IP": randomIp() } }); if (response.status !== 200) throw new Error(`Guest sign-in failed: ${response.status}`); const body = (await response.json()) as IdentityOutcome; @@ -26,11 +27,14 @@ export function randomIp(): string { const ORIGIN = "https://opensplit.test"; +/** A current-contract URL; test operations stay relative to the shared mount. */ +export const apiUrl = (path: string) => `${ORIGIN}${apiBasePath}${path}`; + /** The sync route, as the app calls it. */ export function pullResponse(guest: Guest | null, groups: GroupCursor[], limit = 200): Promise { const headers = new Headers({ "Content-Type": "application/json" }); if (guest) headers.set("Authorization", `Bearer ${guest.token}`); - return workerExports.default.fetch(`${ORIGIN}/api/sync`, { method: "POST", headers, body: JSON.stringify({ groups, limit }) }); + return workerExports.default.fetch(apiUrl("/sync"), { method: "POST", headers, body: JSON.stringify({ groups, limit }) }); } export async function pull(guest: Guest, groups: GroupCursor[], limit = 200): Promise { diff --git a/server/test/smoke.test.ts b/server/test/smoke.test.ts index 0bb497b..930cb8d 100644 --- a/server/test/smoke.test.ts +++ b/server/test/smoke.test.ts @@ -1,5 +1,7 @@ import { env, exports as workerExports } from "cloudflare:workers"; import { describe, expect, it } from "vitest"; +import { apiBasePath } from "../src/paths"; +import { apiUrl } from "./session"; /** Phase 0's exit criterion, as a test: the Worker answers, the bindings resolve, and the routing model behaves the way the configuration claims. */ describe("the Worker is wired up", () => { @@ -13,8 +15,20 @@ describe("the Worker is wired up", () => { }); }); + it("publishes relative contract paths under the same base as the router", async () => { + const response = await workerExports.default.fetch("https://opensplit.test/api/openapi.json"); + expect(response.status).toBe(200); + const document = (await response.json()) as { servers: { url: string }[]; paths: Record }; + expect(document.servers).toEqual([{ url: apiBasePath }]); + expect(Object.keys(document.paths)).toContain("/groups/{groupId}"); + expect(Object.keys(document.paths).every((path) => !path.startsWith("/api/"))).toBe(true); + const health = await workerExports.default.fetch(`https://opensplit.test${document.servers[0]?.url}/health`); + expect(health.status).toBe(200); + expect(await health.json()).toMatchObject({ ok: true, service: "opensplit" }); + }); + it("answers an unknown API path with the error envelope, not HTML", async () => { - const response = await workerExports.default.fetch("https://opensplit.test/api/nope"); + const response = await workerExports.default.fetch(apiUrl("/nope")); expect(response.status).toBe(404); expect(await response.json()).toMatchObject({ @@ -23,14 +37,14 @@ describe("the Worker is wired up", () => { }); /** - * The sync routes are mounted at `/api`, which is also where the public ones - * live. A wildcard `use()` in that sub-app would claim `/api/nope` and answer + * Public and authenticated routes share the contract router. A wildcard + * authentication check would claim an unknown operation and answer * 401 — telling an unauthenticated caller nothing useful and contradicting * the health check sitting beside it. */ it("does not let the authenticated routes claim paths they do not own", async () => { - for (const path of ["/api/nope", "/api/groups-ish", "/api/bootstrapped"]) { - const response = await workerExports.default.fetch(`https://opensplit.test${path}`); + for (const path of ["/nope", "/groups-ish", "/bootstrapped"]) { + const response = await workerExports.default.fetch(apiUrl(path)); expect(response.status, path).toBe(404); } diff --git a/server/wrangler.jsonc b/server/wrangler.jsonc index 7cddb23..ee41bef 100644 --- a/server/wrangler.jsonc +++ b/server/wrangler.jsonc @@ -61,7 +61,7 @@ // Production's `opensplit-cache` namespace; local runs ignore the id too. "kv_namespaces": [{ "binding": "CACHE", "id": "791356628add46dcba93cbd86c75c9ac" }], - // No dormancy sweep: each group object arms its own alarm. + // No group dormancy sweep: groups persist while an account remains active. "triggers": { "crons": [ "0 4 * * *", // exchange rates diff --git a/site/delete-account.html b/site/delete-account.html index f0e5803..2b1101e 100644 --- a/site/delete-account.html +++ b/site/delete-account.html @@ -101,7 +101,7 @@

What deletion removes

  • Your account and every way of signing in to it.
  • Your profile: your display name and your UPI ID.
  • Your notification registrations, so the service stops contacting your devices.
  • -
  • Every group that nobody else has an account in, along with every expense, payment and note in those groups.
  • +
  • Every group with no other active account holder, along with every expense, payment and note in those groups.
  • What it deliberately keeps

    diff --git a/site/privacy.html b/site/privacy.html index c5caeae..6d89718 100644 --- a/site/privacy.html +++ b/site/privacy.html @@ -150,18 +150,17 @@

    How long we keep it

    • Your account and its data are kept until you delete them.
    • A sign-in lasts a year from the last time you opened the app. After that you sign in again; a guest, having nothing to sign in with, cannot.
    • -
    • A group with no activity for three months is archived. This changes nothing about it — it still opens, still adds up, and adding an expense brings it back — it simply moves out of your main list.
    • -
    • An archived group in which every balance has been settled to zero, and which has had no activity for a year, is deleted. A group with money still outstanding is never deleted automatically, however old it is.
    • -
    • When a group is deleted this way, it is also removed from every member's device the next time the app synchronizes, with all of its expenses and history. If you want to keep a record, export it as CSV or JSON from the group's screen before then.
    • +
    • Leaving a group ends your access on all devices. Your shared records remain for its other members. You can export a copy before leaving.
    • +
    • Groups are retained while an active account holder remains. Inactivity does not delete them. When the last account holder leaves a settled group or deletes their account, the group and its history are deleted.
    • A guest account that never joined a group and was never used again is deleted after 90 days.

    Deleting your account

    You can delete your account from inside the app: Account → Delete account. There is no waiting period and no email to send.

    -

    What that removes: your account, your sign-in identities, your profile, your push registrations, and every group that nobody else has an account in — along with every expense in those groups.

    +

    What that removes: your account, your sign-in identities, your profile, your push registrations, and every group with no other active account holder — along with every expense in those groups.

    What it deliberately does not remove: your side of a shared group. What you paid and what you owe stays, under your name. That record belongs to your co-members as much as to you — it is how their own balances add up — and erasing it would leave their figures wrong with nothing to explain it. The - account behind the name is gone; nobody can sign in as you, and you no longer appear as a person with an account. + account behind the name is gone; nobody can sign in as you. Your place becomes an ordinary person without an account, retaining your last display name. If you had not left the group, others can still include that place in expenses or invite someone to claim it.

    If you cannot reach the app, see Delete your account.

    diff --git a/site/store/artifacts.json b/site/store/artifacts.json new file mode 100644 index 0000000..babc4b5 --- /dev/null +++ b/site/store/artifacts.json @@ -0,0 +1,368 @@ +{ + "version": 1, + "inputs": { + ".github/workflows/ci.yml": "2e72f8aa1d99d3a27eee1a71a881c229c27e5ca299fcf3a029e088c26ad2ae1a", + "assets/brand/lockup-horizontal.svg": "0d0867f40d2351996ff7a808751d7b6e44e65323e0b4894bff6f6647f032ba15", + "assets/brand/lockup-vertical.svg": "c32df2988057fc53cd5502a1823f6ce06b41e64e724c09d3f6cf140f1353d9fd", + "assets/brand/mark-1024-dark.png": "7e19e1c337a842a697b0128163e817e231653c9ae0df56f93d241258d36aed45", + "assets/brand/mark-1024-light.png": "045749bea60af190dac860c8a60e84e09bee3e26006c95eb816430211cc6cbc5", + "assets/brand/mark-mono.svg": "5a884c2b20a186824c0be9657a7015a977b1072ab46be5f3aedf31b84a155da7", + "assets/brand/mark-neutral.svg": "3629adc481c5661b129313aebc0a4b57abf7ab4e0942fb104ac872ee4c075053", + "assets/brand/mark-on-dark.svg": "ecb86e692d4522ba53776c4e8ad0857852379e874a5bba29d9fc404decf7db40", + "assets/brand/mark-on-primary-container.svg": "36a136ec365b06fd85ff1b75be8be13b407e4b8d16660b97d53e735dd4a31b17", + "assets/brand/mark.svg": "5b7aef9f38b043e8681def416b33f9a76458d11ae40f58bc85135831314836b6", + "assets/google_fonts/GoogleSansFlex-Medium.ttf": "2dcbf54ad4b5719b7aa6a93e2f1a62657317d0c0f61146aa02f609c2382cdb6d", + "assets/google_fonts/GoogleSansFlex-Regular.ttf": "c16fda9ab98b0027caee4881acc07d57eddf9c1b2a68f91baa773c1c9ca7ba41", + "assets/google_fonts/GoogleSansFlex-SemiBold.ttf": "942649f9c8b84860e7ad491d94b33a14cafc73d7d0a4027421b37c2e48531680", + "assets/google_fonts/LICENSE": "8fd96520f2d0731d254a9638eba7f18a2d8cc011caa1ab3f8c04ca81ab4ea90d", + "assets/icon/icon-dark.png": "872236f03853392973c94ab69c331414943b9ff7051de0b412cc224f1b9ffc70", + "assets/icon/icon-foreground.png": "c50806d11bd0bf37ae363b15447fd62dd01a4e551dce7dc9a365441beb2c3632", + "assets/icon/icon-foreground.svg": "8d0750cfd2d75461ea39ffa2033244d73c962777291d3c1ff53820907af96028", + "assets/icon/icon-monochrome.png": "c17b25bfa83eeb338faa091ba8f24ba839f22dfcb5a7740da140b918b57c434a", + "assets/icon/icon.png": "ee0ac75a31dab114a5b3675ae47b56a5de426391c85eaa80d6aa94b9a78418b3", + "assets/splash/splash-icon-dark.png": "71463280770630b1b4e2ab658c31de63c7d74bb1a9b65c576ffc374b9b5c75e6", + "assets/splash/splash-icon.png": "b43db83fae878bbae24c28068cfae3fbee8737750ba529ae86c2e9f233e6c2d3", + "build.yaml": "0cee11b3000a67014b8948ce3807bbfd67a0371654998e7fd6b6120adc210f21", + "lib/application/backend_providers.dart": "e3cb4a5f9f91ef3675b950e639d1914e84c274b4c983571bb4c4cd9cc7ae72b9", + "lib/application/backend_providers.g.dart": "883822858a3e512fb9562c962f389ab9f277d2b9a9169c612097062696b221c6", + "lib/application/entry_form.dart": "a1980ce819e368ad52501b1eaa6ee73cf858e2e858b9ab847e21e57a6f36db13", + "lib/application/entry_form.freezed.dart": "4dfda75c5afd4ab7eaf1c555874eac2039553f130ab0685e3fb30ba5b47ab17b", + "lib/application/entry_form.g.dart": "7648f069d7f8fcbac389dffc238ea44b11ae3b94df5a7d70a85a588d610a4856", + "lib/application/entry_notification.dart": "6ef66e02cfe695f577e3692831220495418ae02357debcdd2004443f8fa256bc", + "lib/application/ledger_providers.dart": "2898d194a495d14a46fe02b119c316a985facdc1060f6f1006589f4bf16e92ea", + "lib/application/ledger_providers.g.dart": "a3a7e791780c2138e2e78415962eeb94e947bd474de1991a56e7c7ad63c07046", + "lib/application/local_providers.dart": "9f378ab238aea22e521b6fac335c29edbd65ecfc5f7c90529d9c7aa893a487f2", + "lib/application/local_providers.g.dart": "fabee2659a35c1e6eb0d1071ed932e35aba3343fdfd79dd12db196d6d6d36915", + "lib/application/preferences_providers.dart": "adbc1e24f8f13b370bd01285a6c11deb9a5e6f5186d580f87366507aa83d2131", + "lib/application/preferences_providers.g.dart": "7b403d4d33a7f65848a119fabc38eb5df61106eda39ba594d80c9d9ed6bfb62a", + "lib/application/push_providers.dart": "08c9ce5bc143440a43dee608a847f602f903ca6d5b16ba63bc1b39674c986218", + "lib/application/push_providers.g.dart": "a263e13edced3ef1d693f7a80cbe3cfc7e4d09c8616ca40ede3e0976cd1e6615", + "lib/application/router_provider.dart": "6affab66a463755cca047006031be8da8c160bfaed6db9fe64910540f0f28f57", + "lib/application/router_provider.g.dart": "044668945612cfe0f4314bcc597bdbb81963710922c53ba720ceabf34b432633", + "lib/application/session_providers.dart": "4efb2ab1d5b75a6087d4e9168f69cfce011057c8a76a1cf8f021787f1665b615", + "lib/application/session_providers.g.dart": "03d10a8c3b5140e6323a654fda28473b4aadde6bf8632ba288ba3d7d0055b14f", + "lib/application/settlement_form.dart": "db91698dd97b1d4105ba4e1d54404e7de41a0b654821d06b23cba7bebb056bb0", + "lib/application/settlement_form.freezed.dart": "a690359a762fa5293b035f78c3a711ec99ab6752edcf0be70e188b8345302714", + "lib/application/settlement_form.g.dart": "4ccfb90543c6e64cbea6e23b0b27d3841372e32c8d59926fe3fd5c3f67ec2c42", + "lib/application/sync_coordinator.dart": "b8ab750a4bee30675782bd445896797769a9bc42dd68d9ddd82b439cfa18cf1f", + "lib/application/sync_providers.dart": "60d8b6f4ac8ddac58a44c9a147037cb8d58ddadd1b3dcab02af19892cce0c1cc", + "lib/application/sync_providers.g.dart": "6db09dfef3409974a14de438bb2a680c1127881318b57bd36cb6c0d7737ef36f", + "lib/config.dart": "cc86104c8b9d6aa1da30ae4dcd99bd912a655ecf49ba7bfa35e5125449a257d6", + "lib/data/auth/better_auth_service.dart": "c029e91d846d7efcbe462e921f50e90ffebd0413fe1563456993d27002fd9c21", + "lib/data/auth/browser_navigator.dart": "e25eaab109bf5f3ac7554c8e0f20f9a3abe8fa9e8db815c78e3fb973be29bfbc", + "lib/data/auth/browser_navigator_native.dart": "2e62b9017941a78ae3782c591dfe36f4196c969a16309405187e80bac06c6b51", + "lib/data/auth/browser_navigator_web.dart": "50b5b3c75e28f774c8fa15087f87c4a6e6fafaba081177a324694d8a34e4fa20", + "lib/data/auth/google_sign_in_gateway.dart": "2c6c19b0fcae2c36fd62180001eae04276595ae16c9f0f6fdad5fc03874bb60c", + "lib/data/auth/pending_identity_redirect.dart": "de1849b6be0ffeac02855b8f0eba8b0975fc1b611ab324670343b6023f7036fc", + "lib/data/auth/session_store.dart": "0b90ec44a382a99c37b75f6efb9e502fbccb97ffa39f2a6ec1c590a97793781b", + "lib/data/fx/drift_fx_repository.dart": "65ebb5ffc047874d77aa15325b8b4b2088ecd541f4b1232652eace4430ca5398", + "lib/data/local/database.dart": "6e40d2e8e754be58299666e46838b78748a8651ab5acd7413460cc7a8381ba93", + "lib/data/local/database.drift.dart": "00f398408f1bdd308e285c859fd864ac505f980cda6f55c8461016671fa71437", + "lib/data/local/entry_writer.dart": "01fe95604ad860094d34015168e7627f7536a1c697ffffa082cf87dac18d0183", + "lib/data/local/local_reset.dart": "2032e3fa1a9ec575d910e66fc81ba26b7f4322b822c273e9ef407c5068157b0b", + "lib/data/local/open_database.dart": "590bc59b515cad19a8b3bc6166fe64b841d115fe18076aec1fb49f54af9281f8", + "lib/data/local/open_database_native.dart": "28fa1193786d4af1b3f5640b900d71d2b23bb430a768b4e89d56c4b0fdfab380", + "lib/data/local/open_database_web.dart": "7a600eb7c3dff485f0afbec5f1ed33008ba301f231154d84dec6f84ab8403788", + "lib/data/local/remove_group.dart": "65d87800f0870a165cfa67ea0525ee53ba6b438f47ff4ec3189a6580702cc50e", + "lib/data/local/search.drift": "38e3bf822e8f49cb383ddba56d763f28d6f4ce89206d35986a424564ec14ddcf", + "lib/data/local/tables.dart": "248bf138641bc1d264361cd944b1826e28761aff442e33e31334dd49a0501f55", + "lib/data/network/network_signal.dart": "e2d26ef2c96a5efb7b2728e5887cb28fdeef60341aff5f82cd8991ca57324f25", + "lib/data/platform/app_update_service.dart": "07311769e7c1ec60408a951b905bbfe4ab5a16b7c44ac5847ecf0262412fd7a1", + "lib/data/platform/device_time_zone.dart": "930abe849c59c5796cb9ae113d7a1637b4b87cd38e62c799afdbfcfd45f8f28c", + "lib/data/platform/review_prompt.dart": "81db0e8204c1b86c9d95c22ae09d8033447079b8723cfbc7d32fd6d9555ad86c", + "lib/data/push/background_handler.dart": "15d669cbcaeb634bb0fe8d043b6ea89beaa59cb1b96fea5d49f31f9cee01be1c", + "lib/data/push/device_tokens.dart": "7d0c95f7c317cf460207877f51c13d8238e25d8f776ff9dbab81c7933008dd24", + "lib/data/push/notification_channel.dart": "a61888ede4ee7ace708f2903bbb47397434f034d2ce09b9a02d40e70b8c63557", + "lib/data/push/push_data.dart": "d4a71668a575be354e0c2584e0455b57ffb6406664d3cec3fa405e222325d4cd", + "lib/data/push/push_service.dart": "cb9d258232e78eb7e070bb30a89e1da46de0323557d1e0d8ffdde107a472b942", + "lib/data/push/web_push_bridge.dart": "98d7423e6021715c1afa24728f2ffb478127bfa2dc335213bf2019093ce9fc7f", + "lib/data/push/web_push_bridge_stub.dart": "13ddd4a9055b3b84f85662aa986f2b2067cd3f3f64e2960e3b26b3964d5866c0", + "lib/data/push/web_push_bridge_web.dart": "7659eba81f58f546038c7bf0471874df476094bc3b0f1ea996a0b01473a23bea", + "lib/data/repositories/drift_activity_repository.dart": "a82e074b0f8cbdbb83d893a455eeafa5a2a055b126b96855f8da0c46afd1e911", + "lib/data/repositories/drift_analytics_repository.dart": "ed581b371221f2172dac7138139d1ebc8135d76b9a1d51650bf3dec546f6fe43", + "lib/data/repositories/drift_category_repository.dart": "3319560a71542e3c090d35a5e5d8e504ae0d609ea22007477ce7ac8b6f9f1d88", + "lib/data/repositories/drift_conflict_repository.dart": "40c2e243a65967c457d1c82c3c6d4c1d0f1aaf5277e18d97439b9e888284ac66", + "lib/data/repositories/drift_currency_repository.dart": "3e9fcdddf8ee6dd439c873da4d58fc3cda12f4c45fdb9ee6c9b5f6891c0e5cce", + "lib/data/repositories/drift_entry_repository.dart": "706e46afcccf80119f795340c78ca0d9a4f2a93453a5e6eaa3861c643ba20ec7", + "lib/data/repositories/drift_group_repository.dart": "0d3c1f666e1ffc60b75d9ce0ef6d51e6c906505171a9b56bdb2d9eb0c09bbdbd", + "lib/data/repositories/drift_profile_repository.dart": "50ac9ff0a31f55902a10269521020135e1cef825a6ba26155e82f403f8b97fe3", + "lib/data/repositories/group_write_access.dart": "6a87d81b3bd513a472fc6eb4b782f2e93939ddd7aa26c1c78cab427689efc2ff", + "lib/data/sync/api_client.dart": "4648a4835decfd7e0b1f011916d4df33e5d1159363bcde0061713664d0a50832", + "lib/data/sync/apply_changes.dart": "6e19893d74abf81bc4b93492c3439f5a33f753898ba78e05f24f3cc06f54a2b8", + "lib/data/sync/invites.dart": "8675c492a0f0ee2d0b533715507072e577ee9b7f72bad8c391b3b44c8ca900f7", + "lib/data/sync/outbox_queue.dart": "be59c41e1784d8e910b394c142aa4911c72fd77514be1b96329294bb9f8116cd", + "lib/data/sync/shared_feeds.dart": "92c055405c232c909ef1048f9f18ec93b4a889ca34e948d7af827d78ba2eacb9", + "lib/data/sync/sync_engine.dart": "e233dd6cfa8106d2604df31d9dbd9d32fa3f380bf6887ebe489cc7d091ee63cf", + "lib/data/sync/sync_gate.dart": "3e03c5601f7d906eac116ddcfb716416a2281351a97043b59d1de41880c4a809", + "lib/data/sync/sync_gate_native.dart": "642e04b7ff7f7836882190c839ba46c28514da63a11f5604e29998a181bb5f68", + "lib/data/sync/sync_gate_web.dart": "d1686d705a4d384c0d0e3f8c9fe253d93359cb230b5db877487753a01f38c01f", + "lib/data/sync/sync_session.dart": "fa78d07af7c040a18fdebe08e883b6baf08531ed29458b592ca9221c7a2efc33", + "lib/data/sync/wire.dart": "3f563d8f6dd7f232f380ddb76bba8cbd0cdd3a0c5a823a9a023e34537e7e02c5", + "lib/data/web/launch_signal.dart": "c27e71504cc202efdb53b6514a0da839d11d3eb47ce453caf2e00fde411e21ee", + "lib/data/web/launch_signal_stub.dart": "573110032061cad75ba710059b408da16d32663a9af44f5c8c50b24639da42cd", + "lib/data/web/launch_signal_web.dart": "35bddb8f180fe5f475512902c5724b04179abf3f9e198a3f9c01e12ee78b13d2", + "lib/data/web/release_updates.dart": "7a9b5e05c765e77c6657ddb77b698db7ac5c6cb177ea313ed02afd83ca7277fb", + "lib/data/web/release_updates_stub.dart": "e763988fcbd902d61a1cb4a48df55b3761666e45e2c7f2a2ac2d502f5cbb93c1", + "lib/data/web/release_updates_web.dart": "ef1d460c083eab5aaa17ba24ffecef41327a52f6137869a3e95bac825a1fd2de", + "lib/domain/activity/activity_text.dart": "86351731ce1a281d84ded11bdfa0973bcf0d9cb713b9609ea4e29ceb6df6258a", + "lib/domain/activity/snapshot_diff.dart": "96920bd87010cd26e095c50b3224763b45053030ba60b66c93d8f0ead577d5ec", + "lib/domain/analytics/analytics_query.dart": "295afd65d19c05f62d6d8b59f26b14d8774088935b38045059a0ea3551c0bb5c", + "lib/domain/auth_service.dart": "46a6f0920f366435e4fe1b97f01d46c431fe410202e60033ac3e2f352f7d9f0d", + "lib/domain/avatar.dart": "9173e3547e0999a81db575ece2cce4ae02a0fe21766efa5a3c21ac53435f6c42", + "lib/domain/balance/balance_fold.dart": "b3b102efd909ac9e97dfa9bb21aa24518e4f3c5d01e2116d437475e23e8e846b", + "lib/domain/balance/member_balance.dart": "fc96720062b6fc2110ccc096e61fa45f941d5f48ef2c3fbc0771dbe48a691929", + "lib/domain/balance/member_balance.freezed.dart": "311e721e1d04ebed6ed8473557efb54f6cee84ce20d258b1b42d625992442d13", + "lib/domain/balance/pairwise.dart": "205ff44a5eedf76c1d73be9f8406380038f468855f1fa5d2d8b2d0c078306848", + "lib/domain/balance/simplify.dart": "cc4aa9a9cb4c9531990f21d3f233ad1edddf6ce22cfc254995ddde32238d0293", + "lib/domain/balance/simplify.freezed.dart": "af65ff80ca7b0419810b6f83e47cdbae4c05d762a0f5b762e5faf193856d1f4f", + "lib/domain/balance/transfer_basis.dart": "b0091f7b5cae3eadc102e7f9c66690af5195bcf0a166b609c07629843765ecf3", + "lib/domain/calendar_date.dart": "c579028b420658afd9e690bdb0182790645ed6a4fd767c2ce5c611464cc97818", + "lib/domain/category_guess.dart": "5412a5a7f3152747e9c74266cb7cfaf369e4e603dc1f3ae0b88ba3ed08434077", + "lib/domain/decimal_text.dart": "652792c3095bc5a303f3d25ade5ec137d23b70cc003633401d1b4817ffb904dc", + "lib/domain/entry_draft.dart": "1ff37ddedbde24b5a487ae1257471721a8fe865e40992bc5000d7d379c061199", + "lib/domain/export/csv_export.dart": "c84da2061c8abd5e45c47497aeb74a80bc6facfb0b915754fa0764a561eb0ed2", + "lib/domain/export/json_export.dart": "23d6da9f165dd6e741a6c54982389b296db101445f6f8d61bbe91dc2c59c2dce", + "lib/domain/fx/convert.dart": "c9643744ff8d5edd184edad34614428ffb93871a4e4c1d79d1ca195bdba65ada", + "lib/domain/fx/estimated_total.dart": "4a8b0766e350cda73677d0d5dc60e33070396404c75c562beadfd3540c996087", + "lib/domain/fx/fx_quote.dart": "295a0d1743aaa4091574e15131f83911a431ef7c25b5a519700e19b64bc36338", + "lib/domain/fx/fx_quote.freezed.dart": "26901519ec2e106815ade01e30273acf6ca30013631489ec66ab0351a139c0a5", + "lib/domain/member_identity.dart": "f168287ab6b77cc46edede3b2f160d037cad1aafe492c2d2957542403d64594c", + "lib/domain/models/entry.dart": "b0083b0f2ebd5713c32a12360f1bffd3adb653b0203c29504e545a0aa0393672", + "lib/domain/models/entry.freezed.dart": "f54da16e86b499887f657bdaa4062880d402d7ba5e6cc53fc8f472a03401f18b", + "lib/domain/models/entry_event.dart": "e7498f5d7a1e4992918f31d47a046c2381d4bedd364f9c5bbf1b1b3990765a20", + "lib/domain/models/entry_event.freezed.dart": "abe8cef0ff53d4f5fb3fc72597ed5d446b9b0faa6cacddb6b683d424dbaa4356", + "lib/domain/models/entry_snapshot.dart": "301736fcc970a2f5f5c6f067ead22af5155c723601f2a5f234dd31979b94197a", + "lib/domain/models/group_event.dart": "468c9a620819c2032da4da2fd017474b1b13ce6ed40bb19650c39430f7449d99", + "lib/domain/money_format.dart": "414cc3d6fc3bbc06a04f2f23bf0a3b78cd70e8e7825f0c8fc605c8ba6fdcfd0a", + "lib/domain/notification_text.dart": "ba642c6b918f91aa02dca67ae28df2c28934656f292fc1e24eb01393df7b509b", + "lib/domain/settle/upi.dart": "5e35a79b12ba90cb53f0212d58515def53d216d3a7d80ef79ac7a3691f419f46", + "lib/domain/split/allocation.dart": "93a14f5d257136e4aab57e5636e1744a49a85da66e67ee7d51839c8c20d29e3d", + "lib/domain/split/remainder.dart": "212de7c2905e851c529deac0fa6bcf771d388281126485670c2b07f7b1595ca1", + "lib/domain/split/splitter.dart": "fcb96255ebfb8e8d75fe83fe9b32b7b6568c1a4e6906298ee7f9b2216f1a16b5", + "lib/l10n/app_en.arb": "8dd2630475ac7b951ebf1821cfbe05fd888879d2acd355690d95da242e227ab4", + "lib/l10n/app_localizations.dart": "6c20103b522df451d08735e55ed8e620608d5380a4d7dad207b1f7bee697fff4", + "lib/l10n/app_localizations_en.dart": "a0c932261846a75c3bf7b0208a0ae4e48f3593df1b538516177e298728dc2b08", + "lib/main.dart": "2b4ab518516130e28da8276c6eee5cfb25b41b09d88c41b9cc0a5a2306c4fd4a", + "lib/presentation/amount_input.dart": "7c4dd4e6f5736b9b407b116f40aa392a9ea43aaa55ef21ac57f8854f9e4c02a1", + "lib/presentation/app.dart": "3347c15cdb81cee544ffdc4c05c27001480f4642eec4435e398b4c81820aa1b4", + "lib/presentation/dynamic_colors.dart": "b46aa7ffcd2f87e25587e259d021b98386edb4bad0f9f3a1254cb7c98f4d3379", + "lib/presentation/feedback.dart": "4e280d14deb925822e96fbc15a5cd2bdb54aac14a167f761e5cb8520ef9b196c", + "lib/presentation/format.dart": "ca84d9557a6620bba6f8995dbb0d03c90434443312a4c0c7538d20636a57d583", + "lib/presentation/launch_hold.dart": "269d3c8c40344c9210019ea4c84c18995d6bb8b0c971938147bb96977fcd01e9", + "lib/presentation/navigation.dart": "712c335db563e899ec9b0df6c8e41b2e0e97c6887699d8a089df5b9bba2b516f", + "lib/presentation/router.dart": "28b608919ac16459f2ae7be9ab87a3e8842eff3f1eb45d6415c9fbf90dc7dd92", + "lib/presentation/screens/about_screen.dart": "e9794aa89dde035726449f64cdba59c2b87b386b5ca4fa017ae204b0ab253221", + "lib/presentation/screens/account_screen.dart": "3ae90e5a75385b6cb50a90111356353a2dbc55af05ca33e1594ea61e4fbc742f", + "lib/presentation/screens/activity_screen.dart": "9602da6e31a740af63c5abc756253b95a5506dec068598b32c202cc95e488544", + "lib/presentation/screens/avatar_picker_screen.dart": "de23892244dcf40d7636d663e2f3aabe0ad7229cd466110e840be80e9895faca", + "lib/presentation/screens/edit_profile_screen.dart": "5e8068b7d73b7890a9eca287c584c0f62a3d5a274aaf10069c4e137c52d3b822", + "lib/presentation/screens/entry_editor_screen.dart": "9699ecad8d7fe6495cdfe67eca9a20a9a497b5d3d9deedce8413378678eab82c", + "lib/presentation/screens/group_detail_screen.dart": "e0ac5ebd357ce6443b8a538259d22da0978c43dafb5ff293dd80310e028e21b8", + "lib/presentation/screens/group_list_screen.dart": "623f41632bbe5c739969c10b0f64b1d2f358525f74ab469e5fda1ac318ca8cbf", + "lib/presentation/screens/group_settings_screen.dart": "784d499c6d21264ee09305e7b0ddc7eb9a7543d1cfb18ee6054c6a71d329dec8", + "lib/presentation/screens/insights_screen.dart": "7101c8187130ef0e1051eedb07eabee3f182313db0979a9bc575dc028d60f320", + "lib/presentation/screens/join_screen.dart": "be9c52b83d3474312c06afc43ed39e39cca92597fd1b5fdd79b4b2cf61250b33", + "lib/presentation/screens/members_screen.dart": "8d5dba64a725e24f37207d29cd013b887ae883b497fd8bd94f3eb209b580da56", + "lib/presentation/screens/not_found_screen.dart": "cb821cc04137707c42669af36b6fbc58d57577ad7cc7cbe2738daa560a25d7fb", + "lib/presentation/screens/save_account_screen.dart": "1339fbd7ae209d399374b3e80e7d6b892fec0f19666b1d996bc41e6513ce7a0b", + "lib/presentation/screens/settings_screen.dart": "20483de7afe1dce228a0f3f1dd241f554b3a7807e2b40d568d79ed22268de232", + "lib/presentation/screens/settle_up_screen.dart": "e002f7c9f18d0ee95f56e219ba1164b8052caf2ff25d7d4a7e72952eb49d9de6", + "lib/presentation/screens/welcome_screen.dart": "321acbee2761400906b314fda5e88b5315753a867c90b530211f251de33e43fa", + "lib/presentation/theme.dart": "c8f9e39293ddb575f1e4c0f7f9170d30e11b597610adb7569485c385fa42819f", + "lib/presentation/theme_mode.dart": "17a62bcefcb465a5b7e84e35beda4d7e8ed5a5e062ad6eb2ca68b772f0f144bf", + "lib/presentation/widgets/amount_card.dart": "191ad9dc429b878a14fb9682674686de55e65fc4ba5c4fb8fb50cc8ff878edbd", + "lib/presentation/widgets/avatar_view.dart": "84836bb1a6330ba3e013fdf4840b1cbb921ca97200e6cd881ae2cfdc8c038e71", + "lib/presentation/widgets/balance_arrow.dart": "f61c8cda2b0cce6ade80ad838547ca7e3cc6ff18c74687cd0d085d17963d0c2e", + "lib/presentation/widgets/balances_panel.dart": "ccfda936e492c59438e82adaeb73022fc91c75e5d38f6050173f2e48444ab48c", + "lib/presentation/widgets/brand_mark.dart": "99c8bceb9772cb6b14d7f659a3e5b375e109c6d8f7140b511822daaecb10dfa4", + "lib/presentation/widgets/category_icon.dart": "c05e448c7bd4c49e36835e499fc49976fc109670cbe99686cf5e7779888051ab", + "lib/presentation/widgets/conflicting_edit_banner.dart": "246e3ffd5fc40515fecc8012a380e598ef925345f22e8fadb86a258a6ee2574f", + "lib/presentation/widgets/create_group_sheet.dart": "16a64df20a3a63d5c4e2a6886d10f576c206518e299ff44031440563c1218c04", + "lib/presentation/widgets/currency_picker.dart": "96143253b29f35d621732c949c21ea19ef1456f9d046f5fab83698b4b4806b79", + "lib/presentation/widgets/empty_state.dart": "5a0b249bc19037f633f226863af8ab848589c8fd0beedc704b97568080c283e7", + "lib/presentation/widgets/entry_history.dart": "d836ef8febbb1a1b8b619e36cdf74d399de9f08cac8d276f2804bdf9cbfc25b6", + "lib/presentation/widgets/export_button.dart": "4c75072742ea7bc950dc435c271c906dda5e8cd50b0a3617ab2ddf75b7b289fe", + "lib/presentation/widgets/group_access.dart": "7de6f76f3d4994458427e5c6ade54c0dc202d457eccad13627d6852d470d35dd", + "lib/presentation/widgets/group_avatar_hero.dart": "04859ff74e711969ba921c62a24db1d08464851a798d182f4c044a8f4707bf62", + "lib/presentation/widgets/group_cover.dart": "88379edff8ff23b5c3edb328a522dc78832c32e13a4f6a26d7ae085519b08cae", + "lib/presentation/widgets/group_link_sheet.dart": "663df65b3ef61848cb2a829e0d3c4daec284b1a8b456e40fa1e4be1b320944c6", + "lib/presentation/widgets/group_pane.dart": "cee8402015a2fc524d257744ad985178b841ef7b2ced7ffa37a17a945dc3cc02", + "lib/presentation/widgets/group_standing.dart": "437db1ebce51f4ac88a23a0ad069ad7ffe004897c734c7d8c532df399e8d4412", + "lib/presentation/widgets/identity_choices.dart": "37bca8aabc8d10e47673776af51ba5f2fb226140849e7b24c7cce00b27afdb0b", + "lib/presentation/widgets/inline_notice.dart": "95f8854ac4b50b993d52383882b8e19c9ca947e3dcc5547f42ab9a562c6f4093", + "lib/presentation/widgets/invite_sheet.dart": "b35dbafa05d762ccb6bba9b7726f2fdf87ce0fe8ec9901e2e216b68b30215cfb", + "lib/presentation/widgets/link_account_prompt.dart": "da71ba86f2d43094064840d3c726dc39c97394a82a9de23ef002d255bf25e967", + "lib/presentation/widgets/notification_invitation.dart": "001d8a29701307f89369a977befc269b30ebbcd0c1f7c08e42289fe6388e0d89", + "lib/presentation/widgets/offline_indicator.dart": "c3af01e54632c0ac780d17c459b9b8e26b8016f183fd29952971d8ef6d451c92", + "lib/presentation/widgets/page_body.dart": "fb75ecb95d4061938f9a115493d12b3763a31e43735738b89386f86854777511", + "lib/presentation/widgets/pull_to_sync.dart": "2eea75f6426e6f2781ac49e13246f4f864dcd5743ae01763d3c6d10172bf4b24", + "lib/presentation/widgets/remainder_field.dart": "3301ef986b4b013b33776d0b5e2a94b7384a475be9ca4bfc9fc3a52451e55b44", + "lib/presentation/widgets/save_account_form.dart": "2947921f879cbdb354020e636edb3d176087f3b39e24d89367aac3ddea436655", + "lib/presentation/widgets/segmented_list.dart": "fd3316f33853a637179a6f7fb32b0bb419975913c9e7c134ec96f9303f316efc", + "lib/presentation/widgets/shared_element.dart": "56c484a3c6cdd176b4aa2ac8c37eca0c6909309ac8dc539219dba275605832cc", + "lib/presentation/widgets/sync_refresh_button.dart": "126a9ba1adb2dcce727f42290c914db0f9e5959eea6079fc5d3cc03934d969fa", + "lib/presentation/widgets/sync_status_notice.dart": "805cab938e21ee94e612e2dc4e460b7d19359f28a9f83ea9a2ed6232a82f6e83", + "lib/presentation/widgets/unsynced_changes_banner.dart": "a4aebcf4560112429312dae2bf5136451d6c3d4217b123c7e9dcb3199f9f9dbb", + "lib/presentation/widgets/update_required_notice.dart": "8f556ef562e2f63378c387ff45115ada2adc9c10ad4f39ed5a4f792b3d81ced0", + "packages/opensplit_api/lib/opensplit_api.dart": "0ba875882f626de8309013c3e2011ac281a14739560380b294438535f2bbbf4c", + "packages/opensplit_api/lib/src/api.dart": "2f221e96190a3cfc5ec658728694b52233b98f9e589de5c2b428182d9f615e12", + "packages/opensplit_api/lib/src/api/account_api.dart": "e77055289e0b6e86abb082841b871ae88dfd9514ab0b0ac0dad932da7bc88983", + "packages/opensplit_api/lib/src/api/devices_api.dart": "d65b7a702637095758e2d526a07a123785c3d1502e6a7db35d17808d4933d06a", + "packages/opensplit_api/lib/src/api/entries_api.dart": "8ad2ffd243d0328715a5f74d69b4ca55833e7c2d32f9a74c650c2cbd00c9c14d", + "packages/opensplit_api/lib/src/api/groups_api.dart": "57268e6eafee05ade508d49923c8a0bca097297c94d4a2a07c9294ec71390f39", + "packages/opensplit_api/lib/src/api/identity_api.dart": "f0f5ed1cb05bb34dd35650a351c5bb609e420367c1ffa0d8a3631191338dd7c2", + "packages/opensplit_api/lib/src/api/invites_api.dart": "61622b7ec69cd682e9ac92c775f8a231f24a229cb505359986bb03e97efb719b", + "packages/opensplit_api/lib/src/api/meta_api.dart": "f5e82b7b22d2b31f3dc15520281acb2a9a870649b497e683b1b1aae467a854bb", + "packages/opensplit_api/lib/src/api/reference_api.dart": "53861183ed0c0939d9d457fe148d43593704348e029f459b0ce9eb78b9c543d8", + "packages/opensplit_api/lib/src/api/sync_api.dart": "0e4dfaad62a8c5e84a950fd6455fc2260544c693a13f79f3e7e57f3375c29418", + "packages/opensplit_api/lib/src/auth/api_key_auth.dart": "59d6ad52bb4051d78586d056b9fb0fbf9055112ae94eafb87f5724bef9b2e304", + "packages/opensplit_api/lib/src/auth/auth.dart": "be75950676d4a6cb8b00f49cba5a2ab9fe3becf5114ed8851a8bc503a3e7d8d3", + "packages/opensplit_api/lib/src/auth/basic_auth.dart": "9ec6a36a890368c3d08305d60983055e01ee1f303c7c74707d43a7f19114a0a6", + "packages/opensplit_api/lib/src/auth/bearer_auth.dart": "bdd4a283b27c3d7f0f3501096661070493348e99e9fb63ad9e7bdaa080baddf3", + "packages/opensplit_api/lib/src/auth/oauth.dart": "309523ea5e9d2adc5dcfa254b310b5fa59c6a3fb529d823af8ce0b5c89f6ae58", + "packages/opensplit_api/lib/src/deserialize.dart": "ae620de38eff9c6598d8e83d7ce91dd8034cfe5c311ddbfc56d0cc8a6dd21d0f", + "packages/opensplit_api/lib/src/model/account.dart": "f742152363f03fb5531fdea9af9b073ad970fec31f011450d45043e20830135b", + "packages/opensplit_api/lib/src/model/account.g.dart": "3eee4b6070fd171ebbf68fbf5036e0643bd0ff6562a4e97803bf5d820bd5641d", + "packages/opensplit_api/lib/src/model/account_deletion.dart": "21fae919d881c8864bd20b103f7ba6174eccfdeb08993cb3abf00271f2ce009c", + "packages/opensplit_api/lib/src/model/account_deletion.g.dart": "304c32dade8442b6a02ecd088597793210ca2ff681409bc431ce269785ecec38", + "packages/opensplit_api/lib/src/model/avatar_color.dart": "da67aee2d620963b5eca00f8ca89aaeaeaeba35015a7374f75be9ccbaefb72c4", + "packages/opensplit_api/lib/src/model/avatar_icon.dart": "748453d29076503909bb8a6b3c108153f360fcc054a2d3efc423af4dd682ff31", + "packages/opensplit_api/lib/src/model/avatar_kind.dart": "237a4e2c509e17945e4c32746aa5d6f59673bc4e1210776f768ca106250f7dd6", + "packages/opensplit_api/lib/src/model/category.dart": "a42039f4f81ef0d3c772817080ba7bdceedb1e594c995de1c7e73dacd7e3bf46", + "packages/opensplit_api/lib/src/model/category.g.dart": "dd07df8618d4b1909631b0803ed5a678bb8e12b4e0ace3c62b85b732b1bbeb45", + "packages/opensplit_api/lib/src/model/change_page.dart": "3a6fbfcb96b3456aa0a9215bf2cede817708e784d187024714ee5ad2c0bf5792", + "packages/opensplit_api/lib/src/model/change_page.g.dart": "821973362fbfeadb1e92da6eb28911d5039e387d847790d7ce84382d69ea28c2", + "packages/opensplit_api/lib/src/model/cover_kind.dart": "c4488d0cd4071abedc03dbf4479843e73c3481d3ca5f2a9ff015bc4b80a6abd1", + "packages/opensplit_api/lib/src/model/currency.dart": "5fb4d788011a0153ebad95eae582df0893abdf30ec1ecfaf4316da61f083f84c", + "packages/opensplit_api/lib/src/model/currency.g.dart": "7fe2d3342590e863849a6a2809f496a030a4ffe3d04bb0be3556feb0cddcd36c", + "packages/opensplit_api/lib/src/model/device.dart": "46d903f53e5b6cf515ed50a8da5f97018bfc96a1eb5a1252444fcebca56c391c", + "packages/opensplit_api/lib/src/model/device.g.dart": "5621b07e807f26caa2242fa79b4198b17a69856e8d9d8c07fbdb21f0a3882fae", + "packages/opensplit_api/lib/src/model/device_forgotten.dart": "3c44f3e61f2ba9867044a42a58dec858ee36c416f38ea8c54393b1d1eb409faa", + "packages/opensplit_api/lib/src/model/device_forgotten.g.dart": "512d3941e41bcf52af7ac1a538bc6bbcced9cd09e9d59f9a13ce7a700f9630ef", + "packages/opensplit_api/lib/src/model/email_flow.dart": "4248df412f9980bbf05fe41cb2eb34f093cac8424891e1f1e03177ffffd45240", + "packages/opensplit_api/lib/src/model/email_start_request.dart": "2b496c7125ff29854ab670a520922b57f723a0d972d43e2717b00ea15379ef63", + "packages/opensplit_api/lib/src/model/email_start_request.g.dart": "7aca6803d8cf28c33e54b9ef73ead3cca6cd0ef0f4f1a8dd7338308bb8fe8dd6", + "packages/opensplit_api/lib/src/model/email_start_response.dart": "3e8dd5f80a284eb7363868bf0bf729739ca33340ba0a4932306e61fb04d2dbc9", + "packages/opensplit_api/lib/src/model/email_start_response.g.dart": "39da196b84a446916a8dee6ff85c609111d23a2fd99b81a014fe665853d220d5", + "packages/opensplit_api/lib/src/model/email_verify_request.dart": "a622a4f19a3478138a902bdfdf9c3fcf316d40af71b6121ff7df27826cde9c4b", + "packages/opensplit_api/lib/src/model/email_verify_request.g.dart": "ac022b007458848115b94b688b312eb5c8ba11fb8d6bb3dd2002ad0a16325cb3", + "packages/opensplit_api/lib/src/model/entry.dart": "b6ae1045aea58dba542759041c7f1f78d241dcb66a386c4b8f9785dbf682f699", + "packages/opensplit_api/lib/src/model/entry.g.dart": "e7d0fdbf771256730399cd47978fa674ed754a99696cda4b160f07eee1998e2d", + "packages/opensplit_api/lib/src/model/entry_input.dart": "8f32240b95cacafe22f0887f63a909801ea13a631ded1b65fad42734c8f4acaa", + "packages/opensplit_api/lib/src/model/entry_input.g.dart": "e6ab636fc9d014f961d1734e3d969310e5e94e26d3afedf1c80d31ea4dcbea44", + "packages/opensplit_api/lib/src/model/entry_kind.dart": "f24d059cb988b2d6bcb3e264e6c1ee7bd887aae0a953ad5a4355165aeef8ebad", + "packages/opensplit_api/lib/src/model/entry_snapshot.dart": "30fdf815d0a5793e295648b921ea70acc8076b137f61505be71e5b6bc42664ab", + "packages/opensplit_api/lib/src/model/entry_snapshot.g.dart": "ca24569dd65fc35a92e2d1ce24d28f0fc00568380baac6fe1f376c7126148c54", + "packages/opensplit_api/lib/src/model/error.dart": "af42c8e0135f8b416f712722d8ab5b95764ccb77b3661cb6fb5e84e96ed91643", + "packages/opensplit_api/lib/src/model/error.g.dart": "3bc1a1ccc4f939be1baf9ca4a105e53ac67433d6df92178d3ac5a6553949817e", + "packages/opensplit_api/lib/src/model/error_code.dart": "a6176d63bbc46a3d36e5f3441cdcf30664d0d276892fa20e79a73783c71ca744", + "packages/opensplit_api/lib/src/model/error_error.dart": "af9ddc2a425fa97dc0939c83dfe6f3e6a6d349abac9ccd969959f62d47b942d3", + "packages/opensplit_api/lib/src/model/error_error.g.dart": "caeea10e1da4580bd889ee59aef480ed82feaef7a832ad9424703d2ad821257a", + "packages/opensplit_api/lib/src/model/event.dart": "a70a0ce06ed240425917a02e99fe074e07e3c679defada75d149721dfdee769f", + "packages/opensplit_api/lib/src/model/event.g.dart": "a0e7be2efa74dcfd9970d991dfdf86eb9b8bb506bbc907c5e77e4b47105df15c", + "packages/opensplit_api/lib/src/model/event_kind.dart": "6284991c94d9eb1e1ae9382003002687bcaa9d76ba90050b9cf35695a93586bd", + "packages/opensplit_api/lib/src/model/fx_backfill_request.dart": "0957c367eb65d0881506e462c55fae6a1537b0ef3b7c3837335e12990c85374d", + "packages/opensplit_api/lib/src/model/fx_backfill_request.g.dart": "d8e38bf37e212f74235c20c2189d151fd887b6514a19bf74c986452584f0791f", + "packages/opensplit_api/lib/src/model/fx_backfill_response.dart": "d8372bf3e90b96a2b3721757890f6540765e07acfd76e308435ebc9fb1be5a67", + "packages/opensplit_api/lib/src/model/fx_backfill_response.g.dart": "3054f63685eb3d7f75082f56a1a3cfb82bbc26940ea336af073ed5c9f5ec17a1", + "packages/opensplit_api/lib/src/model/fx_page.dart": "6c3bd5f460a85cb49c1ddeecdacfcd69b53780d27c880c8460cde64b8126e210", + "packages/opensplit_api/lib/src/model/fx_page.g.dart": "0254d0d0815b29a8f5938f934886c0634dc87f337db15e9537025a2546428753", + "packages/opensplit_api/lib/src/model/fx_rate.dart": "2a6a6199c3bd8e21643cb77527595b52391cb261a148173afb8704a3f0ba4be4", + "packages/opensplit_api/lib/src/model/fx_rate.g.dart": "f6c2d4c97a832ca1a523e2221769eba3cf1461bb0a2d80e4e76ebc0d2785bcc6", + "packages/opensplit_api/lib/src/model/google_identity_request.dart": "e5da727752c53046bbfce1c7ca4ffb82b8c6c35d1b81359518f703b995f5fb65", + "packages/opensplit_api/lib/src/model/google_identity_request.g.dart": "4e492a0104d5ba75a1f7dd41462d41c8f261e8e23578eb33fcfb57cd66120fa8", + "packages/opensplit_api/lib/src/model/google_redirect.dart": "bc79d30f0a46680907d0d307597b469348b598406b0d51dcb939046250765f19", + "packages/opensplit_api/lib/src/model/google_redirect.g.dart": "f5ae4e7535bb6623613f7c8b663549a3ba45e418469e5d6f3735f8e18c9ddd3d", + "packages/opensplit_api/lib/src/model/google_redirect_request.dart": "e5de8b09a6afc839f8338894c07e66989ce1420ebd70bcaca0fda0b43196fa7b", + "packages/opensplit_api/lib/src/model/google_redirect_request.g.dart": "08248109cf260d275a373d7fc92c567ad41d27b6ab52ac6e316d4f07da3a7652", + "packages/opensplit_api/lib/src/model/group.dart": "a40a9d8fe8faf58da623de5468f227490cdeb59359715cc99d0451d2266b85e7", + "packages/opensplit_api/lib/src/model/group.g.dart": "415b32589c528ac2068c65ac869de5d9c5e2751b239aea92df0b9a565faf719c", + "packages/opensplit_api/lib/src/model/group_cursor.dart": "5cf0675b84187e0a68f99965f88ea9c4ae1e046ad318871908779b38d7114367", + "packages/opensplit_api/lib/src/model/group_cursor.g.dart": "1258c0642904a7076300764a9fd54cf17429d581877fc3e3060b53844c9ca0d0", + "packages/opensplit_api/lib/src/model/group_event_payload.dart": "1a31833b8860c4003bda3ec5993bd4f84b4a8bdf089de704e6ee75e4d16adca6", + "packages/opensplit_api/lib/src/model/group_event_payload.g.dart": "1fbb9c8906451830f8ca4cb7a021ec9549fd66509200fada195ccf0875226c2d", + "packages/opensplit_api/lib/src/model/group_input.dart": "b3e434f1c0a8180db8be795dba9392e251be77a1ce056b0c2faba9083ce59085", + "packages/opensplit_api/lib/src/model/group_input.g.dart": "a152862a0ae0bdb1e0f8ff31385c531fbe9eb9a7834b0704c20fd130d3709672", + "packages/opensplit_api/lib/src/model/group_link.dart": "1733ca53ab3a6d1e360c2cfbaee026be092828a113ec6c30ed558e604d980bc5", + "packages/opensplit_api/lib/src/model/group_link.g.dart": "f002eb4653784468eba6c72dd8ff3ecc78f88df2b5759730b1adf5f2dc62d608", + "packages/opensplit_api/lib/src/model/group_refusal.dart": "a30dd6d4bbf5e8504f2dbccda157c83dd3da0d8338d6c5b8d128a0b27cafc60c", + "packages/opensplit_api/lib/src/model/group_refusal.g.dart": "2e989b38ca18b42d931ca708a7f0ea1f5de3f0b0ad0741906b14f4f0fa069016", + "packages/opensplit_api/lib/src/model/health.dart": "2054803628df782f0c649ec7ca91f748c1a9964fd080aa0dee347c8a7b185939", + "packages/opensplit_api/lib/src/model/health.g.dart": "fa5dd9ce5c0677df27fac047440fba645426c5145d5bf60319e0f9a79b9e0758", + "packages/opensplit_api/lib/src/model/identity_outcome.dart": "da64d61d10db5c861fc532f751894666303c85ecf82eab920d8bad4011fcde64", + "packages/opensplit_api/lib/src/model/identity_outcome.g.dart": "42410f3f071aefe7e123ae8cbbfba37b00c20d981a5d3635fe44f10a8ef9379c", + "packages/opensplit_api/lib/src/model/identity_outcome_kind.dart": "19c61b21a076f9bd5873f892d5cd28cbd4e56c14dd2bf1c7ce0d901012d6a046", + "packages/opensplit_api/lib/src/model/invite.dart": "74d5d08dd03299e5063ee3880f1202865383e450290803679f96fc3514d65e8a", + "packages/opensplit_api/lib/src/model/invite.g.dart": "5fedbe812a1b49f186f944a94c2871389198a66df5e14b672476ecd2014a6d6a", + "packages/opensplit_api/lib/src/model/join_request.dart": "510654c32c6a3a26ec2d7a6e09a21dfd2fb0cc6f256fe23b9a6a103757520623", + "packages/opensplit_api/lib/src/model/join_request.g.dart": "66a2b4090b528b7ac70c917c12ca3c6a0ccb97e107775f44b83f4dab5b88748f", + "packages/opensplit_api/lib/src/model/joined.dart": "77c712e3b81d1eee61f1723b69bcc29148843322ef7e65068565473caffec687", + "packages/opensplit_api/lib/src/model/joined.g.dart": "880e8290a3882ffa184721a1e1e18a1e1b0e5057ca666ab66a736b87bf159c21", + "packages/opensplit_api/lib/src/model/link_event_payload.dart": "6ca9cc9706afa1451c91aafaf02a1d44c0caaf53b0a164bbfc182a2632e74e4d", + "packages/opensplit_api/lib/src/model/link_event_payload.g.dart": "4143e627e0bf7731e0159531bc89dc3adb25af83bd00aa9ac07f000454099f2c", + "packages/opensplit_api/lib/src/model/link_kind.dart": "223e05ea34cc0b470b812da2b1f4dc350148de61ee6a51435fbdf606808d1f88", + "packages/opensplit_api/lib/src/model/link_preview.dart": "e83e6ebacd5e7dbf02d9d5d8ce4da72bcb5a5b1a654f03b29bc10602d6760951", + "packages/opensplit_api/lib/src/model/link_preview.g.dart": "ddcd3a9e6dca01367bc89443a957e69546ff6ce7e6bbbb8191fd9ebc41ddd658", + "packages/opensplit_api/lib/src/model/link_revocation.dart": "dacf57c5a5853561528db2e5b9035739a673ca5e19fc9a8a02606c5d71d02e9b", + "packages/opensplit_api/lib/src/model/link_revocation.g.dart": "a4961150a0a224e2f311f2fb6b39c383445180f3caf4be5e94e305b525167ce7", + "packages/opensplit_api/lib/src/model/live_link.dart": "7f0f68721da64ee67ce3fecac0cda9a634b8bd9b0739a0e86c770c6405fa531c", + "packages/opensplit_api/lib/src/model/live_link.g.dart": "30c439f00ed15d4f61bbbcdd851b65841d0b4558414a18d679e11db99080ff06", + "packages/opensplit_api/lib/src/model/member.dart": "0911c65ca2f90f11ba025eae370efb8bedc4e26159255e2a551c04ac4067198e", + "packages/opensplit_api/lib/src/model/member.g.dart": "379b63ac33cac8248c071fc8ce3a0989582a1667b8bd4d81986131ac292643f7", + "packages/opensplit_api/lib/src/model/member_event_payload.dart": "08f5820be999b7b9e082e361e45b85b6362ac36fe732a56ef5ad56624536447a", + "packages/opensplit_api/lib/src/model/member_event_payload.g.dart": "bf44aea892ba164b389a7968ff1fad0eb0b0ce968ddd5958d3031f534c06e61e", + "packages/opensplit_api/lib/src/model/member_input.dart": "2857f3875fc6bf30060ad02d0205e002d0bfbc3e219b045260d0d4f03e175695", + "packages/opensplit_api/lib/src/model/member_input.g.dart": "d0300e42e0d1f31cf6a6a3e7f24cf9bb11716a281c0f587d90433ea5eee539a3", + "packages/opensplit_api/lib/src/model/money_row.dart": "3765f79379df3deca2998f462504d8ca45d8718df75ef5e0e04531eb1eb733da", + "packages/opensplit_api/lib/src/model/money_row.g.dart": "039dc2f2da274827c819dceb02aecf7ad1abbd3b2c9ae3443a9dce53ee8bf2e8", + "packages/opensplit_api/lib/src/model/payer.dart": "49ac3a815f837729ac153e78e811302424c3d72af53928a0558fe968ecc2a0b5", + "packages/opensplit_api/lib/src/model/payer.g.dart": "bf42674d66235655bf768f0c93d4e0305453041ff61fe6dd35ce34ba86230883", + "packages/opensplit_api/lib/src/model/placeholder.dart": "79c62d0acfeb7c7bbb4701568dea67e9dd0e797d61827f905e30a331e45c7e87", + "packages/opensplit_api/lib/src/model/placeholder.g.dart": "7960b56c91a9c5c0ceda150e8f1f961699acb09ecded3f59de0a2bb72bfd4d7b", + "packages/opensplit_api/lib/src/model/placeholder_list.dart": "ce7270184355ce8397b87441bdc19d869256972acb74436e43ac3bab644e323d", + "packages/opensplit_api/lib/src/model/placeholder_list.g.dart": "551e2b4f616432edae970ec2c066ffe46c7b8f061bace8d2221d6799967bcdd8", + "packages/opensplit_api/lib/src/model/platform.dart": "63ff9e0559433dd2af16631077e37f4843dee980e3b13b17807b4ce473599a6c", + "packages/opensplit_api/lib/src/model/profile.dart": "32ea97b64186925d78c5d37eedc12403556906dd1db01abf923107baadfe102b", + "packages/opensplit_api/lib/src/model/profile.g.dart": "2d8bce2181975df9c1d8868090d09db1e90f9861f49951b61856fb3963fc6c6e", + "packages/opensplit_api/lib/src/model/profile_page.dart": "62b0a1e68f1c938b532130c059a54609945fd8ea1845d9ee195e3ec0c1244559", + "packages/opensplit_api/lib/src/model/profile_page.g.dart": "9fd6c0e1f2c749d6538843b54d0edc252edcde659e9ca47dc7bd6e9f98b9c02f", + "packages/opensplit_api/lib/src/model/profile_update.dart": "44020d507d938178f3a655a45c8ab7d87d60c9d71d317636fdc88e6bcadbeaf0", + "packages/opensplit_api/lib/src/model/profile_update.g.dart": "26c633cf37aef6506e856b7db0c6f6a8122ee60c0a9d11a6c5dcbbfc7439af31", + "packages/opensplit_api/lib/src/model/pull.dart": "eec3f67828dab98790632e4276c472184318d84a8f93e40a7e234057c4e47b72", + "packages/opensplit_api/lib/src/model/pull.g.dart": "042ff083abb8453bbbac1a04ad9f0ff8e4df6fc44a54ec5efe6b4b01bc3a9884", + "packages/opensplit_api/lib/src/model/pull_request.dart": "2074993100e9e5d85f12b09527aaf09fd8a16911ebb520573cdf25483194daba", + "packages/opensplit_api/lib/src/model/pull_request.g.dart": "2442c70a38121ed37c525d5eebe3e66e7aaf6f881c9e0ecaa5ace3adf3ae8033", + "packages/opensplit_api/lib/src/model/push_data.dart": "9272316eddbded35b7702f2245552ccaf72d8e745b6fa20262c7c817f9186697", + "packages/opensplit_api/lib/src/model/push_data.g.dart": "7def6447c80e3fbe1fc4eff9336c267f1e2326105b32605ba73cf09a314800c7", + "packages/opensplit_api/lib/src/model/reauth_start.dart": "858eef8119b176262b97715139ffb5b9698add8951e8f95a695aa48751769c7a", + "packages/opensplit_api/lib/src/model/reauth_start.g.dart": "5ac2ad2978196a50364932909db529574b1fc96fb2232b35d446c26c82d03ba7", + "packages/opensplit_api/lib/src/model/reauth_verify_request.dart": "aff61cd49fc6c8fbc48974ebe28f3ec3ac69a7526721ba54dd7bbf97b7a46029", + "packages/opensplit_api/lib/src/model/reauth_verify_request.g.dart": "08c6317845e5bc1496a228781e5e50a3170a6930954cf9d9441bfe55875ccf38", + "packages/opensplit_api/lib/src/model/reference.dart": "f621b07f637fc8600729f7740cab1e2f681883e5395ca35c54730f9883e2e500", + "packages/opensplit_api/lib/src/model/reference.g.dart": "80aa6c832d229c6f20d57bcd8a3829a23258df866b213a67978fda90f40f308b", + "packages/opensplit_api/lib/src/model/retry.dart": "840aaa27b070af9d997bfa5fdb544d32013612e9b5dc66869a6ece897602bdfa", + "packages/opensplit_api/lib/src/model/session.dart": "235c6d8235007e3990ae6b2a0e07d01dbca76adab277042bd20a1f6743d23b65", + "packages/opensplit_api/lib/src/model/session.g.dart": "fc532f4f1272253b3421987deaf1288900cb4c6853b157741a89af8faa612dc3", + "packages/opensplit_api/lib/src/model/share.dart": "596fc3a3de784d2bbdaea958cc0a5fb2545984af2025a1a276a0a4c9e5de7f5e", + "packages/opensplit_api/lib/src/model/share.g.dart": "17ff331c72c72242b56731122cfbde7b2a603e810d28bf58ee0f135e252c77a2", + "packages/opensplit_api/lib/src/model/split_kind.dart": "38186b7b070123737b5d78cb5f19e53c0d77d95b3ffe78f2654bbaffb23851a4", + "pubspec.lock": "04d8d141c6813eb17eba0f94235b701503a2337ab12b022826476a92d3ae21ca", + "pubspec.yaml": "015a4d60b2b44250fa2dfa64436876c1cb00bf700daec0e482e08ab04133a892", + "test/data/server_reference_data.dart": "b291827d21d1ff0bee36bc7ef86e6ff1618f767e20deb1df796ac911bf6e6b9c", + "test/harness.dart": "0e85c632a2251eb3f8052c9d6ed11b6a5e56ee1a63faf472c045c577cab53387", + "tool/screenshots_test.dart": "5046fa6c017640ee61cdfacb5d950bfae1c3088ad23bce588830c6a52408932a", + "tool/store_artifacts.dart": "573f637363967a67529ce1a33ca4e6178eb6fc9a388b751036653e53e0da14ad", + "tool/store_graphics.py": "bc24ac3657fe43d145a5876c000357a69c272d54c73c6b32bb65f47d052ed1d7" + }, + "outputs": { + "site/store/screenshot-1-groups.png": "83016de59d5a3d5ed49c8ca7204c7cf7460d1aae82fb9e65bbc7d5efa35cb37e", + "site/store/screenshot-2-expenses.png": "e51019038c599382851ebe03ed6fa8290bf5067090d3d4adc20760d8f0bcc029", + "site/store/screenshot-3-balances.png": "cd6fda7d692d2e40cffb970aa9cb6d4a91f97247a06633c847413fd3916615af", + "site/store/screenshot-4-add-expense.png": "0f1d894b2c169e713dc059830e641eb5c4df47ebf007c64469aa629d1ea34d22", + "site/store/screenshot-5-insights.png": "de620ebe0761bd9a2019d70349c6660236fab8d942375db5fd49baa00c456a33", + "site/store/web-app.png": "ef59f4dc9d9197ae0e802abb6b2818d388da835493a2ec891802cfaafe01e163", + "site/store/og-card.png": "4276a3086db2e40680908ad59e69ab8760e6d7f5211884320b93fafb288dc293", + "site/store/feature-graphic.jpg": "39169a7cfab4f3e2e4241553dc543542dd1955f3018d1ae1b8b96b5d6585715e", + "site/email/wordmark.png": "8c43232387a3464261b7b86957495f972a1b4540d122d0cbfb33e5b18ad50b4c" + } +} diff --git a/site/store/feature-graphic.jpg b/site/store/feature-graphic.jpg index 8d72fdd3dd3d00088240fd2ceab040269a31d4eb..fba0d8897cbb745a1359418699a13961a0b68365 100644 GIT binary patch delta 60643 zcmaI7byytFwl+Eh2m}Ho5FjuNkO09U5PXmj9D-}m1PMAg4BmkR4?0Nj;O_3hgUjH9 zySwXU@AKQ|ocrAGe*H(Ss;;i8RnpI@de>Wh*%%S=7~!*@Fwvh9pkqD3z(oT+LdU>- zjP)Fw@D&G<=#v-3ua!B!*pblisHi%4N2Hdu43oa&;`^X(Xz%14i6||v;8pW^Nk%KC z8NEm+uHhI}If6Ve`tFmf8CK~8p589*W=oqg!2t|pMyqleH={zb=30NL0;OtK!<^xF!O-R8jzDvN=?0!`ibsE`BHU zH645OC~_zm`oQ1Hw?+@BCTxmA?G7dAe|c!mK9qzTz?l>|E{YpEF!oGNsJvBN@0^~w zV!t_l$Zk>jKC0V-VU~)2IWeRp{zUl^j`uUk;01Vxr|1b>nE~rV8kQrK@^ds#jN$+v zTJiVd;usGBLI#@>@fMpEFd%TGB5-_oWANmHnyAe8IaaH)0cktEG3G%HCT?OX{nGbM zJLW|Z!c;b;Sn7aU4>l@dmd5w`KISe*IE440R9Q}sEyCS9271zH0{)9!iQYxqV}`)Y zdk`r--$gFPB7Z9cS$hZcB(5)ITzp?59em-Tq_8IPVqL^4<02LCI8+(|IIeAa66XaX zmTnehlk1Xy*$9c--Glf}Z)Ce|%q*CBFOfi!)>`*Q`pgF^#!G*On>-(@}>keU+g_BlUD*&oIoV}9)ziI6Eux1iInnuDw1g^xV(!<^ejUOu<@^rix8H@ z8lM=gw7hG^rpkP8|Eqda`nCz~`}3hkiOah&4jD_y-P;9-tqh>{9(0*-VkDP)XD>|E^R-~ryG`9+Z{d`cyXs1ywcPV z=fEjvStsxbd{TbO6tK@TCBRch4Ma)bgNS_{eyrPcd~sugE+LnU2ji4qq`RzI-h<*7 z)I9FQ3%w}F;g(e*&+-iGG+VCqjDHinhEqjb^rklNc1NvVmVY~5Sv^m@QOA6N5Qw2y z=OfeAot-T~!EU18!~Yb4xgAk;IRwgBd7oFGdCi%Ks!-xw5&X%sqaR2kkWj<&&>Zr@ zlzGUfPNYc3Ag1N_#4;w*9CB9T?xB7Dy^cZ3i4fa+)>DbW`{MUiK)`E61RfpYJvKd} z1P=>vRyr4(efFHRZ3>hW#Snl)2>$>#AP|4#ti)HGahYrK7b6EZVqi+nA>7kpv|7Y^ z^~3tji)3Rzc# z!PDg>pPc^VVn2h-hjd@+srT7mWdYxoOveYWUGXVE36d1BOB&;~F2g5%_-aqRgoL@h01O*Z}1`|!y&IvBlrA^xCno`}{Za$MMv z_#vJh^sh_<&(9toaQ=5$>ioml`IjDBKh+3=u9vHq(((mu5{sL72XY#mZ#3s2&Ch(w zJJrVn8m%jV5a*kp=kq57?41QoZ;zPYl)M#{TIRbxzMl1(=shijDAKnrSGk(}m^FTF zbZ8)+_e0^u2fIUko+O+__CGo8%>uuPs{19UP=}(rlxYO0QZ-l<<$x-fzT>$T?M2S? zJt#>9D>nof2+Fr?!g;&;abo`@x6q!+MFjJueI))Zxf;y=N&W^CNKKkX0vpN@?upec z{@Xwl`ui8+6*?c{JvzBGQ5k`M-D_$FnXM+oO6q$hMBSA_&kM{a(1!)WK;eunEOIDzLqi2N$prmbz2$muxB;~o^^v0~G9loKILNyU(mxvS`}ctJNGEsg`I=^edzPzb#DLj;xxO2m1@uby34E5R{T^|4k%$2*~@-NR-9pG-;Umc zT=vI{uoJRk?m@ZNQrEfvs!ce1m+If&JQSJbs=ig67MWE%54|-%6?tgJ>7kj2R^Du8 zHg9&$J=Fw_63JNbW9CK{7xz57oRIY|B)c`+dZ18xl5|Ydqu#Lt>$W1TJ$s$z0pU$DDK;5X>MYTyb! zxXIt|3#R(lNEaujuisei+k`EP|9j+%QwJzb4|5B0+})E#qb|q!Z5Lz9`U-SkpA?k`ckA?w9nL2{w%j)c4_V7E%+?ZB;)TyAVcc3v8Iw|37h z`m9Rcdn>p&v#YVtb>0N;>e8~)D)wpUrTW(RwmM5?rGq{H9R){4?cOS2#^(Ho@*ANh zWOJRY=8b#Km_SORMZdWb>$+}onpD?R z07lwqE}T8CTQPDiBbLn%wqRzvDA;v1X;xX!e~bp)(qu z7_A>&%S%G>m@`@k?BL%9-h;NgtrE^jr@jVZCvlB%|1_JaNe+BEJSyJ32!BV(YkuJy z(%sL)cM*SQqnZ*G4#)g`@?iT6f(MsSvrmM+xKe#PrOL!yeEtl0{v7T3v;PDnLh5=> z+1xDY1OTg!H=Lc{Z1Ne;5oC<6XWuEba5yMdqQrXVD+;~m$xRMtyM~qZqN-MRT3*y& zqg7VB{nGjD70sxJ`PQB75SG`(+(Z3beW6Kan>`fN{B;=G%Fm7Lur+D=bWVxKWn~(| zajmevf?y}0LOVw&5@-MgX&hMIjFg+ntT+6)G8jAV`%ON`B#b4Ti3Ghv{vyq%&hu&$ zNWCLBMzJq7D$v+?XR2I zn0d^-GqnsAIWfP5OI-_n^Sh|31+QrWYVs(sg0RFvc7oUQoPDw+F}9D0HR8xUEq>TO&aJKOUqo{WvaMF#xS`BbYt5sJn#TWrQjAcV zv}CmIkN83La-h45D|GVM;n9RO95>nRylHFmhx}#ZVkuUiv&4`O(pjg!N`cWH%*-u& zsrrcoiar}8BfTj4QMk(Et6Vdq*8u(`7i7hG4{F8xx&>h1*p>GG#1_YGvebUVnKn$! zN=M-+Px^SYq9XFLmOmJ69ZDKe9*-`HQDA>YHqE`pPrf*L!jL)OYP5F2$HHHF`b86* zS10vm*>mipjmES&ubKK!_>#VnePyPqu61A4FYmC#hEwp4m{fcMxQWiNntWcjF!H@; z%U<~g)h%FM=VndTn*u!db9FP~$|%S#2#{B#c2N8v*mChK&s&QaqI(kg^xh4V$M?7c1qtpep=YgkMCfj79Zx0G7Z$wkL8Ys7E6=sfe7bUevyF?@1hpr_g~dU_0)Cv>i|PjXm%1Kwg-&nhE_~2d_#v;q?Xzfk1>Zx&S*d`EC0fImc34)9f>{QcEE~E+?;i zru2x@#}##yAcO1d_zga9N{CHYh+z|dC3e)?1ZlbZJ;Q>K!IOt^a~F=3gnz=57q^oe z^)PZsIN>Wy{jDYMuTY_b(^gt%mYP~UxzXmqF(N}nWE`V<=&<6RtQcoMS_E&}qR*iE zz>v|$4aPz4s&<&go7TM}A{qg7bE$1K|8@$34u)%?9J5$@ek+d2;TC%YMOv8UDqLq9 zmM~JDr0=M1Zo&RdTB3Ij-82Lrcxg&8e4g|Tiq`B2_;%dTskCM5Y8|qsPol*6#<=zH zB_!lXrE|8JXx>P^bvCVMx&FHstPOzLp=-8%Gy4*l zx#Mj;WT5Eh3@c}t)x1S-+f0`l^el(k=+_@|fk-z4v`u)Aq(AxH-xSkzSRWk<#yh5&NWQL%c zz7u7eMZR|}8x3N9a}U2DBl3M$w}STG*9KC8$l1;tR7_<*Yl0g|lJnZ+?jb5|$?IKZ zuqJ~;C?utAPgz+;*i%_^t0J;*O<9v{Ir9{*-YH^bZ0PXH9M~K>*SP({-#dtT@ggZ1TQlDK43 z%sgIE^k7aV1aM9Blkv3BcqZ^nEK#5W(mJOd?4wt6vk?2DR#k0y>c~Blj#5Hu=@Fw) zDv@!JR#Me!sZ7If%FKp-A!q2ZK5@r6i3iDap|(+RJTvzoxU4kh+SBB{6IWsO=T&3d z2}vqNs5S_14Gjf)BJP}xr7Ndy2hAMlEt>RI5OJwi1FoVy49O&j#V+AQDt{06OF~+# zSNm*PX;0`qtl%i4<~EabuJu4Xp(U&j_8*sJ_lVW(wRfFL)rVw23P_`OTx zaM36K0D66UhHF3c8r`;_KEe@Y%b~0_Wn+3oOVE#w#ZUeIT))wsCbH!oWskR8)@{_g z1Gk6ZUCqO$-A*rKd1u*Yq}=$p^U2bBPdOJ8HwR?i993nxX85Hgm2pQ?l=iLHC1+8e zHIVRC{`m&S@J3vTtr$0$ZwQtO3WWs?t?@ZK0P@?m0JeDLu%N1r%T)e2OeEG{;7rW7 zeWPQwiH_NjC@8EbzJv&l>#5gR=)s(sU29vce|JzVU65P_!EWZ&+Drf{}fxq8yERE(aZV5|cSCe%$libTeix>6tByI@%-_`-FQ{@nRk%q8d) zUhct)V|6>*!j~Lhqdw*ke>hO8ZlomUThxQ6! z|8esSGUXxuUMYEd6Uw0Ug1+ubG4e+W7ZpV+{pY5fCv+I>P)x?I&sl<}D*A5J-s`C< z`p!2vUzo(Ry*dbKdsgL`8=|HlV=6r>FVqJ&vXi%h9$*%#phBSu8@au)l#+np!hizO zd?MZH;2(tH?>PlHc`+_`4>V5?cP%G@H9l(!Y!OjWF{`w6SYdeQfJ?UNyUS+ZnyuW8 z&7~^+BpGbhpe)du5Bxmd?Jio+I>Qr>!m&6?W^oS<-8$g}$00TFh|^SZW(5seGx<_mOW7Pj`*$X}y(Q=gt!WOH<`h@KTyrMm|l4UKDY~cY@7%U*E z`PgdJgFPQT2RH1ote}0lLWxGRO!a`ds(7BX_sEw^=BgLuHk!kdhiu z7Zp*0!vNs!cwwC}HP?fxhuE4Y9odwMSUp}yY5&e1JLd*GE$|~=T z+Nz?#$gh9JG$>N+5j|;fcl!hRMlL8Ps|lN7KykCTYX z!TY1vD4R0zw%WLco z0hd*u3|}9W^{hwtvU4M85z5}%O;I|PUDBL|jCE=_E@jN_o9Q{MYtIz0JvevQz%(zV zk3qfzg4}kNrbtV5fC}$MT~or_;xQR6tFpRY(xQ^S*%C##nwW~zD?0Drk?);b{_N?F zpQ&^l_p>2Am8&_&awb`W=`zCrd}z{BhYRs@7B-5$d(aQxZ({R)22SVb#V zt8;aFtxJ|Ge-`BzC7u`?wX!|;amE}F!uq^i!gr2YCz@hd1$eCMoJIyHtxs3lIxRKL zS9MNFjyI2OCK71WU`6W8rblGYnd=yU7OUT%R4uWSduF`M3|c_eGc&^a4ySIWAoU6N zpeMM7YIkc1`!+Wt-v{<3O^LKkg3QOPe_G(ni2j_VZqS(7LYt(j$aSu5Ti;aTvNhwj#bk18I{AxNBGCQ&ZPE@ zD?83uZPqsflM;U@1I(-PZIgQ`i_Hs*Zi#t5xkM&2H)2gtuX(utnpvRy<8djCWg`@; zw^2H0UuuQ2~ExsGd^+;>_{YAly#dOFA=O|ZEDMG&bmWXLivM7%6iROc+{86d_YiH z8|HEkdY%Z;vZ_MDs!_#GTv1Wtd*-R3Od4fudt2k*=GLnnW*uz^fyP1A2efPgIEiHG zJoYo>Cmu(7HZPqeMEA5YXIE;d4|lPQ5<&rM(?92rjOMdlMo-mj z@?(5LM*1s1Dq}sa;a!o1G}TnyKH|8o%3Fr=jQGZ4dwpa$t;Zj_DmDGCoKedy$tLrZHw88@L;O(1KteSa3kv7i_sm_^Ev+Jw@h zS)#T_OUIuIXWT4>lg*00>*N` zRfSH*Z%Ugg8NdPJ73G~>&;{JT*7qPntY3QmQRbluYv%&sano-`)hOR`8Xui7@Bq|2kX ztP{COZc)jUa%)asEi^0+6vnYezN{&0*1{RToLIKaS62tUkf$x;7m!~dw>dj z&}W~Wi$6==UfgL)-a?;!@aZX0_RzzXi0ZIucV5IMjdmqPWxrOdPV!q+mEKQMj5=qE zQ3*mY9$(}!Dzv5LM7LwgFh?6!kJm3B;;gtAn7*pRF`PDoG!)U;i+tmK0OJtJtMHQx zUAt2Q=Z2!~rPpkJuV2TG;Gw++;9$zX0gR@VI(hJS%AGRD=uvkPOrhOkVyfG3pYVU9 z#)8`Ns&71?894Dv&KY)hsz8TYo=(ahFTmftRI+j%m2AMMx0ieB0F%!E+O@aOr0Y+q1Yr< zWv<6kQ9dfhS(Us%{OnBmd$SX3cG*uzl!laX!?FFahRED5crc|;{RFOsee_8va(A0r zMXi3jka9chple$nu8!Q^T|BjJ6S4TK{M?o&0MRl|ME>AIuvZ+>Z2z|x^rv~mpM90jTP9Gghgk2T-ZB|7_Mv z<=YPJPYv)~O*Z3oEao=pE$5cj`4=MH5p`4kwO1M?Incj@>jZ~7g6AT#C*^169f!}= zQM=_cw5wsWMqWCDUmuonr4C0zK|}5fn4f9L)YDL^cQTrAb!}Dy9U$YIy26~F)EU-2 zsMvp5#UN5Q(X)*R>(9H>La&Ch>+(}QnN^sFz0|c4#r!&66w^*$WckXd*wa3t7V93< zyy?A#c%rQ_sLf)1=>uN7b|Z7hCgMdt?;HK1dm$!XQw)krIk%8&X%qEd=&RP=#8nVZ zmr5IkHJ%x5y(iF31JHfsboc(*zoitz-I6)$jBy4TGR`UB*@<+k`j+cyHo zGzk9>fcpUD5+|>!7}g!TB&NlO%J8hA;FR-(8OWsudxoz9Q0YM zp4A3)H8UYxw%jF;YxcY}6}lFD4*r440PLWbnV~N2Lk&Xy<^IOcndGwfNdL9M^2Q*O zyC3$~tN+>GMpWWuWqRxhy_~&E`KEOdF(Pf^Iy*quezU5kmv>hU0V^KOUp>nB5!yXs zcV&BO-~g@9w35op4N(zyX3F)qt(#1*miV$JHBj3_55cEiSow}~!Sb`CmCTR7hvep2~1 zAF}^C*6wCv*5`dDE`9aj=04O;K9@e1KCVxcp_B6Jj zGlw&5CWE1QB!m6(^*jzg#KV9n2P>$P9;Nfl(SFq}@5LMbkLyOQXG`or%(|fWc2s&U zRh!K{$RKu8gWJF;vBf^Pxc=3O?GS1t18 zz+&cays_E;<>UCoX@PC*TZxKzCuan5c3#iA2Sw|T(Y1!0rX>}+iy6k12~xck^uf_) zW~^2neYwgxDnn)@14cvsrt(!@6ixVe7d z9`ybQCyS_5GsZ4WE~!UgA0k;uRC>9V3!oGU#qY;sn2A|ZrTbp z(PRf&b22yTCKpqQuoIBroYef!9V?2{wIAO*q&Tt~ujoO2d!_6JrMMK7kPT<%=FAD#)5m1}TVOnz%2l`yytL9C^ ziPOOuoMQroZ8JZt2s*kRW;L=%I6boOuHUNF8m{A&4NGxAT$>30BXyg8+<;j{NJSFZ zKmLC+|I*aZY(6O!@)q$HW&_?KNd|LKrZX#dQ;PpcOYQJsO7$P9-t9S={3j*#|59Jy|EUzw()w|B7HhL=)nIM+ZCT1c zvPe3GR3u4=0|s0rRe265?neD1qwU2nYHv0-gKb84%lGU7tL%Sdek6Uz;JV`fp|=qa zItjQLqE4Y!*NG`8#(yN!O5mZxB@#(8|NqCQ6+o5)}IJz6_y7QH|(c=avu%e~(=kw4*DWmXyD2EyC_ zZP?{<^Rm*iJ@rh)l|*089`zh_a(V1QK|Y2oUj9lY539Ty%Me@S(SP~90+KwuMLs-YuZQkZ3dcl?xi?N7)=QY&gQ)9NP_UQ_15s7YPwLEu ze%SH64s+*@mhX=lOSOYl%mETz=r)fW8&%eHre*zHy3s0()Sp`r!I8o^6!>fY1={G5 zP02PY4`qh%MsXsNZC?TxsqpwK_Z*ocQS|y;g`KSWx~RRsotfQ}Ow7JI44cL|zyX%% zp|Ge%_auuBeg1l+z#Gbcach$25jPrp_#(lH9h^;x&ZQs!F^jH&7m>NN$LfTTpzQE- zePoiQm3$UKJ3+hNjqvdb37h&`{!TA)l3wRF8K!pDXX8XVC~nz-!R zzUH;PZeju#7D0cC8G_#li#3a0M%%MJwXd1&MDet2nVV*WcPNt?HF=k>rUX9V^fFz{=P;6ZiT^My?*ixHM-37d3m|*tepLVM*=dm z?!UjVIV8&1(dW|cX*eZnZ?pf>7d5GtoEVz{Sk<4GzSJ5Q&aCJUjzfVI{UEiwb)D%H$br`dFG=FR zxSU0ACLJBS4RRL5&zn~2msLFuG|O8%&HY-OGasE1vK(0G5XQ?e-dwrg)JjR$MzQ55V zrBtemMyAf^9!}=yWAYl^*Fn{-dBw>V0`WLqR5*;}Q@rjn_^(h{Ts{Tiw%hGvl!;n2 z9Xlsn#8nWDtUkMjueXT0Rfx|p9&G^^P&Q6!Dow>4R?~V9dMY=R6cjM`EB1M_l?!65 zDL9#KX|mRH_-WWq`fKE^jRHJdnZHod~TnO$7{o)BxC^T#PYKu@`&45Et^0QC6V zOWfr0)6+;d*rF$Ne4W4>z3~?yV?Cdz zRWh)1oKJ%MsqCip)L4cTP#*zIpN(-}gOhS?>j|yN?ow?}XvZP5p3$0Vh+x$+)R_Gz z<=5-tFEb~!N%btj{(6Ev6-WL`3q3iDSzcl}@EM2E+C%R&eh+GIGLX-kn0-3Zk5jvP zK4XD`)%2@#U*V8HRvB&Uds^Nz!Cz*LPClEc@0wIECOhaTq7AzO2EWve)>G6he|pUA z7}Fg-Y0YM|#acQ1ZK?Ily^N#JLBAV3+z&m`y_WJC_YnCh|D|R#D}Kb3S%{fGPT&(ZE7S<0$*jgxLkk^#?|lQrC;{nhs`HU1TXv72BeF*&eKy zI0CL7apx;TYMb}V`2!PpO3{3S7)l@^lmz%Z+1*LE@NSHPefp5a+bs#r0k5P(a5@YA zUq0m&IQLfL6t?waoI?dU;_(juCCBTMb|#kYEbzlyw0pf*Wa=z$__li?m{i??SuQ`wL8dBRGw zBH?lJ_vkr&2?xFt=YtmIJklh^hA$&C^YFaW@yYd+7^EBr=H)hn45b zjT{FXL+zwun1>-XFyvgTlk0KiZ@h0HP)$|J$XMH{yd%%q=elpS@y^JKcv+#^#DgKg z(Rn7XXhxzZQPNGKLg)Sdn2S6nowIqAl3>RtG0TVunSjlmD;|+Jv2-DZT?Au0)b>cde~GDYuc*h+Hs@KsA_`}Z@Rpx~5d(1W zBh4zKTy$79wI^=4qv?odX5CaC-(B=%#XX24wZ@p6P_;!>cS{UI!HPXR)?QYxYB*wu zGK1wfi1J;DHL*3{Y%_a%8E)?pACraotR>eqAWrQ8vju|APTz=Dq0Sh}%ahAfBiLwd_UG3Y}w7ft$boKc7)Z5v^o9?UoJ0?E ze^$^whE1rzmRX)w*KXhV-#-iZv%H|Z%1_w815Ytv5zq!W0pbkP`&` zds;L%#K-rtKxV~FeHv^th)r#AeuN`U`_MJBt>~@BBEOJByJHluxz?3iH-Xtgu5~}V z@JAP;u&;Y=Z29z`6~-2b(N+A( zT^`sdc0-Twuin8*+|F-9*Srbo!Z|Cap$6+N6bW~Dv4#h|9eXi$5*B%0ujtf^Zmo=r zYbJ{*(y|`&?a&#;%Y6-~&xX1~zJHeiBVh|cdh+r@5W&jm=;tjMPaVs8ncK*~k1$3wXoch+}5ZE83(D|puVJO`w>M`Y($A3sicEu{we z8{7|-%Hqt?Knpj+Fbq#FO8pxcoxX$-{x~Jw?xd(~aXz?_Y)L=z>u1flTpLHbKuZ=) zzIN`R^3sSxj4G{js6*#E8q!Pej~HwupqAO-7NEU?q*cckNVX~JB&~3&F zEhR2u!lyXmr}N(^Q%IV#kbdq|6AGa`UT~K&-lg=&{)M0U+etpb=0u`TSISM4OVvNW z(L$2qY{H~gkF7Op%-jK;Vdnj-N@4=Rh?Lwjf3(T$qZu;R6`ZuiaC3R}r0%Jfc(oI94ve-(N{ zRgor|^d?fA&n4J*4?Pc45=mu}*EgBtGj5yMo5~+BBS7^5G;+NK61|$sZ3ya@Ub73; z{MXeiaSVy{*P}@`&iQp%i;njoRsH_Z{4EdrYrUv}e8)#6-TjMkLi5j%$5yYQSY&8e z6lK3Ej*gAmiq#tLlpBT(wKHwIceFwz6@%2?jk6Gt%Et3n`c{+ki8@K}zVokHm+I3x ztQrI?dC%p1#>YKj1FUB{g-6?g0%yIj+Bm%p2|YaC(%tA+N2(+5=ft*Er4(^9!fX36 zJ6ejMm1(=xu$^E~_0}q+Gi~;%hRkyMf{nhE?%!7r29mW zC1qxZw_I3NTBP?+)<9r1=*lj5XvrmN zLZ|7IA{~ql;v`*Nm7L6w9;vBt&ar2duq;0CzKhb-Y4VZS*Qr)kO1|vUG8|W?l4~G} zuwPvQyukgu_5dk;nVz5LJp1rZj*N1z5Mmoi(jP{ zjXtr-kH@CCU!=Quy84UHzfM8n=N$&=PzLHLE=pZ1^u*ktu^TTb8cU6rOGzOa)v5mDb?A|*9ldg zvx*7&4#8lrl4K@p=*v%P>kQ4QllFK;RU;_f4K*`Hc!5 z-eilXr!HB5ZI7O^MIUERpVQ=e?uSXU&z4)uzK1>FrMO?_=oxEv<^+JtRKxGWBm4!b zpk9hB`3AwsWBKko*XPfTXa0xT#R3Rklc(&Ldbxah)KxxTntOLiy8a_srLs`3Mx$j( z5){vYycAm~R89&-IZ+WusCb0u?33JyZ4Xt?+D*R~4C`)k*m3vWpeD&;L- zGKI1&YDgUcn(o?Z!X1jHpEIN$W@YD@qKyey4Ufizrom;F6>AZ%Q3^MUEDx$ z=N}yHan%yh2M&B1uFmDGTKCNC(A56i)MD>cq|P>3vp*DV!+FM^>%>8K?PT6=nXSpz zp~ypMLXD!HFnK+!biL&-Oj#h!N5f{Q;hP?S-i%-XQ+CWw)br|8=5^S_?|ama=;W9u z405aM?)tq$97|ZVuI@y zQ@nIB6mb~a9va$#$wrn3X9`w>KGtDt*EPG8I?$eiJ767CjBU{4&LIk*>cubkC`*A) zxwbn&b&|;88S^!^N{x@pfHSNHtJ6HW3Cwe z(hHOfukb8{R-W=jo7c_m%x%eDgWJR|$zMKTOlL0IcuYy7--@Ff(-@C6v@Ax$p@YGk zwjw4e7%o_3>9-E+78DUx7pcvA#d1x->MjTC8Lek5hh&8ydWm_#6cbl>YQNb41 zAAa-FVBbrAsOWf+clhDPUOOYVkuIlYe-;X`DMG=VkH*Kx#9pxxzw2k#1OC#^#}(qS zy=v)b^Ndk#H%*I2hAoaw!3m}yM@@^CzGIw|Z2O^b4ap%nU%@^j?AR|gM&9^4xC*E% zG2`s&;i()(){hTNu#q;%JEP0jZ#swm2wN)=J9w!Ai6R{$#K=FCw)<9Mty6a>K@cEuL`7X-h;;9H>CaVgGkZgJ?Ogsl=q4)<<|I#R7?Z9*Nr^q9U}K0 zB%#!(wIugF>D^LHGo;4!ydl!(qceGso3dGs-+Ry} z&!aZmLi@MIz4LgGdyuh<$f?D|T_mk^Hw0v*dgdn3ypA({H8ZeBHn4Y7f1F=Ed9>TCSG`+z&ipM%h&FRE?1fjj(1@}kIHSIs z&n(|*{xehKXWsI*Dm=&!LqOO7Zs;GgU8j@7a-nCEH70=vAtuh#wy3NSE)g`e98DD3 zAU;-N84NtnLl76W4WR$BwBUW-BW23{lOWc*E1ZCP@hneOhoKUkE2Hcv*n_mbKrJza z_QQ0X7|&(x`zbflO^&?!?$)(HoOF7YF|IQ8{q?sMA=Q`X?2m485i*L(ESvi3=9EH( zal}VeDPpXbo_b5<3T~atoC-aDNK)_mJ~qo*{!90hd6UX!BSfd79$GMhR*CJA2Pr~L z=?y8LL@4d@{`uk-{3MC@ipi;p@6Cehohl;Y|KaT|qoQorzu`eZR0KqtAyh!5LpnvI zMWh>4ItCa982VC>5G02jN*ace?h+8`&Y_WJ=q{hR@B6>^yZ3tE=l%9Pi*K`7a}INI z9rcUjEyv^C_R;wbF~=jVtT!|yxGhN7$+h}pL~z=oY#^vRmh z3o(woM}I);Mf2Bm1+6}We?ayIuxsnGKcK<@jYfx~RP%`|yU&+>k|7=+Ae$J+3M1yP zkv?yf=*(@H%1zAdNZKVL1Xr8!Jx(Nyd@D^m>bh9SkI6?@%g6A$OS4ny*^->} zdxuZ$@4#wBWi}hXkI@UZ1C{>xjT=qS7n}852UMD)d=bB+K8q(t%kM7y>I`fwKH#G7 zl>5C}<;in=sfHzyA8p2^z1;@IzuXk&2Zz!$%Ww&NCJ^!d0QmQMxm=P z+7RODrT%qH^s_9qVrJ7&zyY0E6}ua*mfj~`1o_dNskq*hpYmql5W%ZKhSQCBrNN0K zfPiW+;$U_88YWoP;+_UGo*U_n9D-W&HE=yg1AVP{Qi?%Da|AcagB!*sR!RH!mu5dwnno%@$ZemVGHhQ;rf#Eb7zBO9% zp%Gkj+auRon9fXtPbI}~iJv28XwYb*S0ltjf-6~XccFIX(%ZAxVaaaD6!Fz>InilZ zTUH-Zlz7bjlf#PqP(!pmx?4X*@VL6OR-!PnvJXsb1tRsXv+PXJq70|Z?lTYHM7Dl z`R%IS^4z$ZvtLT2F`F4?iRAvBO%T0`iotTVg5zLN!ASu`5ILn_o%x*BlbR_fc;NYJ zWN8?Yqd)}9fLn{h+C!yHCHDNclO=IqkzF!TliUv`47p>5bkxUQ!p-*-2UjI?7Yt;y zyUSk2hvDF`0Z0^7Rh3KT52(NfzIdRcIx8>SL)NTy(eXny920y7xx!{H?#elj&JPa+4%ZEsjrsLr}`W&vwq2A`&iLGAU4fj{(X ztk&>{b89b@SMwFFpZwFu{%0Et{C6LV|AJHJlee4lGkuJ(dI+v&?W(|%?4tG*$Ff`)}S98(=jaef9DL(lr$@*kW6W zX(Qalw9TVA_+poQKEvVc8n}Dd(|%eGZ7vqN;HYepLybW{l@IH*u}JKTL^6V>4OUa? zj`6}n$s5;Kk-s&DRNx&;NuxH8t1H9uks_9qm!a{-6+vo_It8k6Wj{_dCiD90K5C4QYAO*uw$s~$F>46!e(5vAO<1GEJ`;Dr zs4Ik8>HRW4zgdq81bu$%(L;^V9u>jIzxl3h^^7d0s0r`d;Gijw#EXPg+pl+ui&Mua zAHSN$0#{|POf_!QP-%MRu9Q3I0|h!i2gewL&MS&PAeG8`u7QI(PtQW;VY6x|)D>UP zZaJDnE2-_4g5*p&)kpZ=v;?dDgkv*5`0Mfs0;)%IJNibwR_YWHsmB3ay*Z<*+&wl@ zHr_t<+EJCYFCQ`F$V*Xi$qpZ8vkXz3#n%>}?LBA7vpYBLS@J4eS+SlS)x968!@Irw z_G$eJXVaLv&DA-6@r07f!r<_m?vTLtpYlrH;ap+KyIxnCe_ zuMf)UJoskY=ql&0r!kFyowI)9mTAt3k%4sXgO#Y&eX}sgH0>0((=Yb5y$=OZ;pUb1 z{nLJ!N7yAZEVLNcvckr+Eb2@@ub$rXDwefRH9)!h=yjvzfM!!$DvyPGwtQ@9pdkrdCV6O_Gyefu?9<`LJ=o%kUt9j%MOr z4!w><;%jax;PmPbXms-r=;M{t{c|5pHbK{ddf|<^65olF*`%MwUyS|VpMpE;uJywQ zr50`SeJogB1QdD005M^C6GhyFgT{FdpDzS8c&`iFq3D4ArbF!)#)h!UP(iJ}#BJDm zTk0b93RC=C>{^2Tu2=Jm%14FGMCL_l1ifVvA>tekWWV>u#w*A3j5gu4;>Hq0){cdO zH{dR-rg7XQkvjPR`(bg+4frH+Cj8qRzrrT>b3=I3@c4KL5X`m~F2Z6zy8o&2ISpq? zE~i5uQw(YE?69leMWa-j577PxlvFb2xJLvvQHr^2Dze?(S&0YYr+!R1CPL3ori4@p zM_zk&>4r^n2~%^qR=k{NdE7W5^;9#6;I{n4Yj5svu2yUZvqs#ssvl>aC(CWONTavN zziUz5t4+cy2U>&zZQhaWWkvIX?B<3Szi$mf;-73CnH_jvEi554X-c~J2;HI=C<~ij zF9o)n)hh{%aY0dugL3M;(>6=F+avHbi>JK;Yd z#O>2O&7@pe0{QQTH}qG~ZBTdY{uH|5XaC8%Y2(z-p2D zSeq#im4aIrzAa68&K7kQ>rt;Bf>NUhn^W{zJdEJG0B)7F!wDxAR?ccvc821lkooVy zY(5#)3AMwwQ99zzv5K z*Du72`RfcRl%tBY-%Ls=CM@AaHQ)>|EbNFTjn;@y)^{LBBBu*Db+;gbKW3c*j>cSK z)$cw|WerR2tiXyN#2@GaX%~I#5kDQ_Vxj#(YuNFldjG+`MMKL4O7DyGQ?^NO9RUx) z#Y=B3GczX>A65w_L(cHJx2y2bA~n|MT%&R9IisTUb`3@55NT20p6>7`$0$s@Ntwy9 zeACD#!O6N$D!JDf|gCEuocOF*qK z<$wL(mMf?iCXYq z6%Dy&{zu;fTn#r1Rb~Z^*3bvLd}jPKlWvma!g-Zm#Kvl%AR@;D^lGb|N+Hnb~ z9AZ1IhwhQm8T$Q~t?bsGo2AXYpE04~$<%#Dj3J*puJmRU5v=c0RUw!g*ooP1|q%N9E_G zin%3qEQ&qfUq0WU)Xq+wK2aDGtUY3A+`N%4I26rIYg=^`?!xqn=OWZ7&e*O`E_AQW zBCZm33mSl%Lm$RZu#yCKRm7v`IxaG^!;>bi4aX&j^)nGk*u#Ww7|<}{b(`gQ(0nu5 z>*lOYU`UGxHu>T=^EM7%G1VZCyzn+-pF-zT;>|3W7jp{=riX?X=@$UWg}V%&49q4d~+N|*v4S?6wk4A=Gj^Ek=N3dqUoEA z4<2AXqrSZ|+S=@#=R-5d)*c!lvFP!3R1r=S6MXg?fxz~i6$74EPwTa`#gH5M2`u#B zYP;Nz<66=&M>Fyt-p#LaGb(?XlQgc@9WmxQnh+C?*BqCnfAt2YqGUErH!Oo03=c_v zI|&KwF{{?^{b44fM8p;;9G}(}R=A4p@ijidR*xRJ8+L;Qg<~~;Ko8;oF{(aUd=5^k zQ~B9zQyaQXrTMF9b8orXPkWtvaoI<}ev#$kaK>`37;?HKuV7ZMIIu32={ZI_AU(n> z)3M>q+)lr*ryjX_3kMIg)wkJY-Ni)_M|fQpw+yLixv;HM&ifoWK=!lWY}!*a3y$w` zSeP1&rk~qVcOS&Og!ln>=S(=Oe=+Ch`U49cZ`+n`lVFwC5moGbBe`4dON&ne2OXWa z`q?;XKm`eUWkk~tlQ#cXumK2SiFc5`a*(W_Z}dH2+oPm!FJI=( zya*VD-nmjZYjdX7n>`O)`*3fc>?<*INrR3ZRq@pOB41%h%=EsBq2B9gzFngIuVxL0 z%`uo65~5TEz@P@m8B8t-E)u7;`7pYhhc>o&5xuu+NMsbdSN);e=Ss&}p&vI{?9jQw z1?-$-5i*OKHbg8p$5o#wrZD%ZU=@`IOIl{%uL3FBp|eUTMOf zqEDZo7oft%4CsTw?Y$RM9x&vbsf&1g9uARs>a{w)2?rFFohq7+oRx^`0{bVNBpAse z6<*$X1YmU%%_8a_H{GywW;0MklEtoXhz)~bdL39Q(flIp*op`lg5of?!>mT@J-ecE z)~xqn?5Bnkxh(`n&M){ikf)z(HNQ4pdPuP;d{+uVFJ?(#*HZJjZgh%AN7;Hky$Pw$ z&gQ@?KR7wd^O^1}hX^DR7grPhPKG7puzfxYI8@%ARPCf+yg8IoiJ94$SH&NTiEfp% zC&8mR&$ct>3?rDaGwkUrg-+T-#4gu!N>}4iU)yB)H#rRqLmNMMQFIeDeTuFx2$P7~ zIWth2kF3OiFB>x72~vu$2T#zV5lr2cjQ1+v!agyNTB52P{tnw=H-Cj~SP+C3Xx!Q< zHLHWz`q?}wLD`=ggoOqOHVG4G^&Vc$P7CjxR=_zX`o$_}tBpjQ>Xs&B2|~R)vjUIJ zv%b0_mECHKa~%)T2(!!-l4^C-+Dz|Fo3#-O3TEETL6&Vn?+Y8wI@uHy!Mp?x!ZN>P zrRt#v9cx#`V{3+7_P~J<&n72etdMe6vD$FnP?gZUdKQyCM}}I@dZfaz_WRd_V2Q(Z zY72?#M2TKJgTzydam5mKJHe1aV`}dT-FPH>jarg1xKl%llbhgI>_@2Z`rPm}ym4$z zFKq5LBWE!*uFBT)y(GSOZUaK5Ud3F>F{~}A-akUSyOJR|^$}CSUcM_p212I!BE)Qe z%~rRUw@NR4nG@P2n~N#*ZQHDwzrUN^tlOzgUO6$n$%@1wq$A8<47)0cX?D<6+6k;k z)i9EA)ZgnWPCE!4y0pwq7F!ONDjnABQq>a8h*KQkSjj=8$KArTnY<9MY;#CTRUcA& zmBSJ~o4;GPnEbV5ujsW--ACtWE?EKV)2Zb!TV^-(e=JkBl=PgEOq-}(^sWKCO zBD}oNrkXKjnx5FM-m^KPPaxWH@lZVeq_1jKGMYK0&*=~7L*1uqZR{u}GuE9a9l(UX z0g5P1k0VP48K7a;`iuIv8MjjNF^Er=Ou+W(R%%O~x={m3ehJCXV~<2uR^dVh69?7m zNlfzx)Pw>ww|#ak03+nj{{3An)x~9{S=lSX`< z7d3|i!e_ax$d)`ew$be{$191FXm%g;^99HvB_0` zPTaX224-QI>v0PhJ+9D(9(D*gY?U%TaC;OM{OExUs6^7$bcj$*7lvHPK&I2zMO(%5 zk40%Vy$4CRT47UgvpmuYrL+Coyp`63Bx^f(Haj`#|GV)hG z3r79K;cIpl^59yL^awue)5heO+#~ zjIzC3Uc~-nxaBS>j5uJW--BKl6sZtbbA(zqo_JePHinw)Bit4f@1Xg12hVi6~Tk=@FgeRKYeMT}BV&l|oQYqj7s==O&&* zdna5r2T7`(73hZQ*v#xEPvUBeK|!~K9{JBee%HvFN|H|!!`xBb0%mLuq`EqXoK+zq znws|fWVO5pQ7j^?CC}F0G2h&=UQZ6fb5@NYws~`>NzK#=b{nySz>Y)di^+B!a1%hYL8DUF6A$)@ z)@w$L)f#myX>}v7We{05k~k8GB^y~ht4|j?2sYcV0M5iQM^AmBDN9mnWu*V`Q`ZYM z)6qIHBOjykJ-hx*&gj1;Q8DMz%8@%glh&HeE9^E6V=6ZqIZ1u^%#D9QceFH6Q(iX@ z^0HsO{RM20{!OXC#w0AI!s1`#`BE%-zTu|1U)pr4K4^BIlq&V#P%aR^Sus~;=VQX7 z$9E=gA~Vp{bk#6>4&SPVT;vA_ZmZqRF7Ga`9;hne)S zfMrB&&u~_?tZYAUiX#_mbyuyVE<+xkW#`6aZyw=t3kT$acS|e)cqJE5LwtWGb_(IN z^{iR4b*J{?t8ax7wH`pfgz0N$wfL6sL@18~8od+zuUr~2B#BVhAJf%tL z#Qm;qn#AlJ8Z&-2ufa%tOBfc_gaO@7;|lCP6Y z4mr%=85Y?24V$G!YSzA*SsGhu#$6jF8c#WTt-8e@BHG9^h9P{_=BC=?*V3IECBCIB zrWH@Vg;g?0Bpr73-@fW!bz7^QK{a@(eK&fu0c~8S1_DyYoa*aS>vF=wqLkjkxlfaa zCgxbsZp?^z)Gtz2D!TfF_JP7pQI<}r*M;TAn0@iCbCvBUPbSy?En)e`W{^@c&FAaD z=_DY*8DKVbI*E0q@CiEnp~j|%&nA(Hs(n#Z(fo)bhJM^Nq>d#*SHm;KaM^ka2tQ3c zqKNFc(B|4q>c}Fi@ES~Q?vuX8Z_fUGWW`!(mOP`+w5|Q(Kx_Ym{hJ4mezs(aUqW{I zcTI{f>cU;G=|`*1+5GD?gg!jsNp<^AKD3{)+Kx@r$sbT3$Dy4OiJ-3RoasKtQSu8X zE+;%vtd$NGjz+99VdZ!wb+uMNsU3k@$tOK#z$aZT#y3r~02P zuzJy<>8FLLkeqw92E)7f{qJsz5*nz;;Ae%^R--)@+-|Y@0(q$5kg-?`_i>Vl@fDA- zlfXX9YF;H>Q$ya6JG4f$gma!ay{i#fpQ*GA&=gC$L04;!GCO!N7VSK`N&d;*2KP9! zoClsGhu3a@r|<8xix|8&_3WRoA!|Zc-SF93%}l|gtgP&?5jBYeA(5r9Ot|C@6sHI` zpLv^#-vTg+P9@#hf#k%?fSMCABsgJi~ zC$8ClJ*^#?-lS16m|C~Yj=pQu?^f9tmFa_?fYzLkcgkV^E2!w~PO}D7j#M=4D*9&w zOq{3DoO4BRR2C`_dC%qqfAIUSyRn#gEAKe}5g;MbZ3FUT%v;*9;$Q&V#u=4Q^YDr8 zSGhT>-v0_UkL;cu;J`-os9JhqJimKSC$5sVowu;&L0W%6Sl{NhbOL02`YzR3z=aBu z8ui#mV!ujp#l~!Mu?D6%>3{e@C)1H&hO;~#5!8Ff8B;Z`R|i3N-9x*~*QQ^hr3Kim zf=<2$d+P?rv!~HW)ZG$N!Mz@Es7dE7 zPs*AH3Q_p>mymA1Lx=48>4L`v8O45LNZVlJKDr@EY@Afw86L*Uw1V?qy;4V9KYt25 zNZmAS7;T$Xfg|r))%_@jxu4$qB0K|&l=?Js#My5ZJh?$)DNw(U|0&u?B?2~DBAedX zq%UwVZ=U~G{O|To%T|P2tP8TuAH)jG*Tw58lQPmQV-0Jzl^BZ0ZuQeOPaLZ?>FxH_d3gWOA~J{N&e#V ze7|QJNzqNbiB>tdRVy=VrB{$~&Hhb(%FJ7hVQbpJ*DrtRnjOUwepLD;VfKDR0QM7w zb4h^zLJbMW0(2e*e}uO{yO=@cH>`9Fy*hG8CaQ|cs>Nt(L@S7 z*sXqrXW|Mq+_<&G6qMrfOW>zJdFv)bz%R9IOjB1@%MuxG-IictC}M-Pe){nO*Uils z^24Dlg$3=3N4?9Ef7fkW1kE&J3SjSB%lgmJyLM|+kL2_C0Z}3~lo$#negp!KMvYzM7GIQP&rk~+?~Ho`o2wkE#Z9PBqlfpX za|%)^>!j4k+D~bO=6P0#E&&&#tsg*^uEFtyX{irK5A-X4^oZy2fF5#QNuxXPOslMW zPFKoGz-#Cu+-9Th%hY~RAX%66zTJkJeW{7eY;gDa3EQxAg(vq2GA9P>^*nx&U(=J z)T%`q7wd-nP6aYW!kze zRn7AE3o8FBN{h1{oc0}XlEs7~WZaIan4+>$Bh5`DQRc1Mo8MSxcY38q#Musw)<*?) zmYUwFX$nizS4K3sA`|&TDWS0hEQw^ujjp z9(n~72v-C}=w%jt3yF5_yEucjj30*2WLl=H-$1`3M?U9m&g?CuS|WZw<}mspBJjXIP*%A_Aku1+98pHvDf&f-U z)gJUPae}PYqJ(<@qN9ATN3I6_$O1)U7B=KwjDX)|01k|MmnT0}%~Gnj1%bs1+K72v z+xNh?`An7IHJ5MTvo^WW@;!_ETu8InkHWG3HLN}td99lyw~d||)u@-QBpf?HRHEB} z+E!CF2?3sL7l=6njosv0jbpQMt1)$jBZ22i92EcCs6l!n8lwd_K%xVlFIIIhJKb(& zGrL0qc%{_hmYBBJyy@96Te`g*h2B0~xbXdDyThv?xfEAOS$Zh=dB;`=@79~O40+?s zvELW>4*r0?>?+GUJtvFi+7atlg9U11jnyUO1@jgC&FD zb$1BOE<{DjCg{EU`DfI$50u6GxH5Vuen#t{fyX(CWp&@=Wn5&^`&J}F;q!e&l@a7~ zVLp&<*Yd~x6*2lHLK0OgTw~jXwL;Ne-iP8y9SI5h6IeWDZBj~jM}63U%U)(WztdU< zr6gZry&SA&M9LE#tMw?@R9hn8;EsRYfpDgb_(O0lu1^pHRCMF<_%-!JLz@cJ+w>j^ zs5Co{92rqHR?s+~o6zid%)^;d#EO)i%FO90KQkHQ^fx#nAs*w*3HOWJiX7@u*CfJa z_(i=sszQCA1eScemwv``=L=YgGI}Wp>&Ln1NbR>4bJrmro#0-fXTxk~%iiOw6%L(| zBUHi}e&Cscr1RtvJWNy8B~PUAuGGT-X!^FiyVLBpU?W#z>w{HfZgS1lVi6;+w%ds| zRk)bXhT4)HZLOSoy!P@`QFX%I{LP~MVdd5H1GU%S?%kALr}OxnD^VzGuJ`LjM(s1f zSKqaUCBGXNQOISWW<|k)62#$VA6cS>XPpR2@xMVThhXvaMz7ZBo^J@|inUk+oMZ5N z+kS*vU+Y?#OYfNCVVwgR+WHz)x0=voLol6y3(YdZSRpH)iBL;PHHYuh?^8z*o`JOk@b=0=M*eOAmEi)QaI#5OMWrZm$ddXu)?Cw-e3Wn4C_izsizYNq)NS|I zsvo9`7+=%TP~C8UCa@Bb$3Jq);bIT5?3PF+8v--&g@hz`D^26$j$gd1+{(uPe zDyTREg=fnM^W4iPZL;qp^Qu=APwCs=Rz%VrkZ6|6M!W#G`Z#tw9tHttCDezE$|Mw1 z6x{~(#4;Z`tV65Aqi3xH9pujmR5X}j5d%BBdxVmM&B{CZdL>6Lv>T9W3+kgozE~=- zE7*pcJd7Flk-3+g2z`&}KSYi;2kD8IfF-^Brf=7->DE(|BWvK2V&o5KvncbLPNk*K zxDH)XmzZqIMPZNF^{@b@3AFOQieLG^OW%`{XZz;V9V&(HgWp#T zX=v4!3O}uoKU>aaJ#*D+uebCz|9Y93Z;6iDpb%6Qga~NIxRR>jZQGZskhM)Q(Fxlj z7YF^Y!t2lELRg?P^5>?X+(w|QvdYaeukjI{E#o_>!G|!we;wYbZJGbjXE6B~*o_zOQ8R;mffh|n) za}H&cutpga*b$X}LN=73*JHbt71O0^A5wN4!wc{J;#ZBLg z)NXJv&~SqFZzrV)ddovGd4eB3k8w2P;EJ=Ip0<)q@3Sv`ar!;dleBke2>+Ziywr}~ zf%Mb;UG**Jf|T@;5d}kI*t);3@1a&dTAiYrgN9ND)9Ej(Q6W)A@>{wJ zGOGIO3M>MJ-m$(#ykGjf?%YcLrQ~9?^LR1WS98dSpW(?S)YCT45ee6obNNMyEz}=H zO8zgo$KSgKs?a7sl zHE-m3!|B}pUf3{&GO2bSS4~ZW6Z#gLl2CI#CQZ>zVhM6~MLPvnz`lV5;)EqxDE@MffS9OKxYe=697x?Ee?c&%>3#J}d(Ru2sguA+= zbG>tEX-2V9o48I@UG2WBC*YD@JIjU{)3L?X$DK8#w{Z_mTsZh)xG2#e?t>J@@615N zKyert1qG`v{f)i>b@zm4IPwCt)V#GYi9;1Ft2^xUtbwO|{xNaYB^<%Fh@6$`5muld zNL!8g5?+P2{%)kJwb=dKE{CCqyuzgap=h+$SbO&ugW(qDV_nFzFN+IhGQGnDjopd_ zVg}`HoZMwroINeUp%mDD>_58kUpjwx>BfJx>C6YQA2qH%7Qcn-O#fZwZ;5pnQ*CI! zMd>vwqXNuyo|RF1-tTv6>xvPsJ(y`4Yv3mv@9zpdg6 zWNEwNAwBXB#HhP&?)#5z3SH3sBDqp2uIU&aDU`tKyC9MxaZ6mqwT1-TJICFc%kAL9 znxFcAKs1OsUuK{cOXTwKG_I7Z_EGK-$cfGpOQNjoaxb=1vqhCxyt~2#Vx49$=Tn>| z|DsHtLC3)Ka$Y_rmu41e&$55dK$hevj38U9!hH4Ts!oR730lygPH|%f7Odu@M8T! zAwtRkqss9(OR2HjSQm?!ny@Up{3|`fqj=G<3*~*PK#X|tgzpolE1`8pZ44T1S3)AG zlD`biFMvvm>+K&kaP9gmy`H!b;G9<9TA1@i5wnSlHXhpNPS<|^17eLG?D)%d^PT78 zih5vL)Yq=ib+mOzf8$9fW)}u$koc|g*1-3P(5v~Iy^Om0VIk1Mgfx-0_4L$t2H9!y zOZ?T&%=pt4Y<3ud{Q=D$6AlB4Yr8_DBv?y@KcG4Ct-c5ap51P2kj}x99O^e8oO%(Z zu7^k3X1h{!YPgKhb)oXi1dhs$pV7QM1I%Fl$>Z^Ec*p3aX6b2vRW-+6XQbF28Z~^D zxt05*KZWdHTf8bVGm!X*^{U6e5O(agI!wCZI84lMw(^KLnw_}T`lVa#ZgrQAcYKMG zeYz31)iTw~g1PM1rGVUkSNeNyR)?%;<&5PEA-UG7962zqUXQl8!O$d*{u_{CCUEQW zb|;YWtohf65r#;fIoSV{{F3ogOE$kOPZGx+NtzaHR;;N*6LVmBnCfYnrLXNoI=7&&e|E?su0Bx!!?)@(3R5LK z-wJf936$&ZgQb$+yLsCNmeaUu%So0+eOKU|tLE^S0ng5SBf7zX49MLCuJ7b;a2vx% z!}xz5*iK6%XpD?vFI1iR?L{*8ZyUJfp=hV$7mm9}O@BbubW9f+yT>D3v+JL*kV<0n zaRre6NuGPSgV*T!8>l}Jx}eus%~&D3+(%DTm-79`L^E8nwP>SOQu%6QOhm|L4n9@l zAr40QPfEGKsP2RyS}p;T3*!o030aB!v8XEmCAGtc2&j}qje zfM~^jTa69O0LyE=rBaQ4|J|4-S3+n#JmU8$XXk5|8-H4$);KSK$SO}tUWW7W(hG@) zOFttQXTLGBl(D@a|Nc?v)>Dx5;yqz+#L=@lceBoc|KLV9E1cu{hw*X4pHX(5N@H`= zj&5AC_sx~6>J0)5Rmr_+e6gT`wC%qua1ou1;XDzW%#`kSRaIn+`Fx^yAp9leL%m%N zP-S5d>Xa94;E9}KgS?G*$*ume#-IB1oWupj7@7q=GzoXg&y^rvkHfJ|dp7FB+Pp?F zS~>u)o!KR);P$hs5FI_jM0J$P_npJ7iJ@4JUN;7n9yJ!aVU2MgTV;41TW#wp-AFg) z#*Y}g(h0*(@@nFNurjS(Y_rIS$A&GiXuhZ1eNi^?^CU>H<)RvE4HUpGM))G-Q=!SQGYwKhTQeZg!JZd<{|UT`pzMQZ*kLJSeT|x_uA#t6#!QCVhR3)QmeMt zaS-n_Ndo>#8pU)YbiC247go2K&zu4IVdsP5hd(gfNL^nvQ;1tEj>OCki=3Cdk)#ac zS!DkbCQfBe73jbB^xdufgN^&+Nf?Oa4^|p$yBzcV8dvUcU4n|M%ZkqoRZfoUH^aS5 zYWC+6(bAO1 zx&>b?_A%}8xMO`H(ABp`PYU0ciS6s>@Qt9`0nSmWVKB90ztIauQNs2jc(cJl%7@X{ zaX(knas{~8>Sg=Int zdc2F?4L5WEVq&Sh-hst(qD5&ffq{YGE<48l1x(b<=@hwG zV=}t{h!!E#_d}(o$R^BU*#N|Jx)PzspV)7q5qLpqyh40NhlrF}2Z!{x* z6VjP;98yfUeE`0z0_+-yz22aypzJO*}i8t(ZbKR4_&Aj3s z?Ra|pXAji0?R&TMj7PF|pK3OuRY)3-k%DDzOva~TLT~50`=~ChjSc&URj#!Vc}mIp za3IsICahCizx3>B=Yl_e_K^K;S}{8agll+LPKh@!Fm4}?s(CRHY)t4|Ej}}h%dkKz z-;(-H^y8B@v&9MGYV&H9G4I&p7*`6Nsl0W17>YM|9qXx`Qpw5htSuV-%49E8vfv(ksV@JGzCdTU}7$b88{cH!Q9k=A<+d4`_M~n_9?rK`YKbD%Pl4Q~{T` zI|XU#&b%Q=Ca)+2&O&$oK80h6r=HQz z-uI5wlgod;w*UPLoTls}MwZbkSzLSffS{etHahV*wF2VItv8eTk{}zSiOzQkAf<;{e+i5q(+G(IiY! zD-d$j5%~F@M4C1Dy+pvJ1K@Q!gK|87WvzBy@&Pza##Xk^!tVY7Rc@=@9ELeeXdm=k zexGMtXqXXbzFRxep{8vc>Dfrc(v+%*S_#*ClI;BxxaOrRMLAy_O;cc{`X&YID>x~%sIRjM)x?ES%iM5%g?Fp zaEa@cmMAr`d-ApS!jpYm<%!`$G4bc;Oa>i)ps@%lr>m5Qid6 zFO5S&ktG6?pzvES)H}|+{|_kO{SIB3Xfpxnxo-NPX(Mednf@eOh@m(>pC>8m{{e3v z0-&p9AZhuK2d5@`glfI>!PHLzHL`D6>GevwkV(uRkZj(x`yHl*E?iHK?ujTkXgZF~nQwkWg_2Dt{`Lg|d z%21U&@Uy&#aAcaQPX7ELKirPU*8F{2KibQ>!<4`;w>*uD>1C zDc}&IBe?CQVl+Y2_6htzp(TJwTFB%XAMO$(;Yt6sI2^rhvf@OVT|694#k*x;Ou-kp z;`3>WqMhMJQ2!xc?wY+6A+U%@4Wq+ZMAU~p0vIF(Xyy;- z)aT`iik3?!@wf!-O0MWJ-dGh!b->nJS&a*Lb6afLa~o>er1Hs;!DF-q>Ztyc1NaCg zR<$XGA3T#+Qk6zEW`#V}e*4osN=$ecJZFHJJSEP|xy-Yx2X5imNPZ&N=NTw@C_;e- z-ndRk|0szWmg5pUsZ#7ost#3^D2}5s+_%e&%3{o2XqHiV1I77a5&~r<5~vG{c#W>S zZ%M1tR|V)42TGpqou6<;B}L0JOn{V_Al=#FA|q)kCX!(`VaV3l4Z+Q2#PqXA-=Yn~ z)|oO3_;`7hjjrcuoUBk6lRshLMX2{OJhouAXK64fBvY>P4Xn%N2hC*f0 zYuPMe^%K(_nCH*AZ|Bca<~aY6JpYg4S+tqihcHcG`C~t=OQuN0CcsS=G_%1Ir(`Ng zcQDVsX2!5SOT!Z3Vb?fO;`ok z)sSK|#f6?_F$EP3fVxs>pAh^^W8~pR0(aP?-I-bsMm41V@B1s4;&P{ybcq+HdHfLV zcGh|$NQI>zQg6pqpf%Dx91$PKTEXbiz9YYRnJGq?JtI9?u;0kuxl)1NaZPR3B#$lE ziXT|4l5(&yT9OHE=cX2lU%C3tm}bmasU~QgMy% zJa9GD@aU{bc$9uVDJcy=iOpT^o~ngpyV48qSo@v~^!?pPG0~3m6kx=1sMr`Mk>jEd=_31<;b#!kf5JBa`^7I2FFxlLp^>G;@XAxx z-K5bvr^u-CUXNYWnUI|^i!0ab*K=aD8%o}z(U75}hT8qzp+2bf=8tl2a-XLFl`2h} zwLp&!)b=X1!}_b{bg|I+Bu745XV<6gn84nj;a?^CUYBlz6cvMfCU zrqD}YMr%i}b<|+y@O@O>x(PW4(7UJd|FSz^y6M_`O%Vk>N80G_X_Qtxm5oAEfp1AmaDs4}VElEDPh5p?nF%&N2C zL-8*M8ea`2CwPJGjK12NKcK|E+^}$_)iuImzSjy=t@h;Z#o&lRea@3JgDWvxc53i_ zKH&k&3OH*%Yrdos3z5kD@XF;(>A+Sj&xLY!pKtK)Jbe_P$=*D!QS$n@;AO>pFr7&} z@fe|;>dy%Uqn;Q(8AC3P&ls~w`aV*;aDDobYSTnSNi^HT{?KB~aD(|##?7T&+Og-* zc{E}(nn&Pr#9Bz45e05O?=w=^pk72BXV&#NkJ_nLDZEb{v)dsb<162Cf!~$f4AG4m z%4$Z+J02iZ5wC7F6Ce->ZLAb`1Hl2brj~-|hK_2A0y3l``spgfj#Xm~3I(U=Z@w%k z8-~lgokplU+CEFSGbE#vNa;3`2!1u@9v`XCT{G*`#@J+-Q;l7?h-Gg?yE!Z2g@GwW zn_jsw^^~1D+-p?-wn*Gzm~kZ3-8+2$$plMI>eAgneK|Vu3eXFF=R;?L2g5hO+nvva zR{TevPNk_Ux`hKze>k`b8~Igv1$Y-WkIPT7#&!&g2$tY{i}ob`G-EqJ zm{C1#z4$FVY=DO|@L3!6@bXx@;5P@)m;=RqO$Cvq2@KJg-HC3T?hJoEkG4^wAoBY8 zlGkeJEM1ClHj=L%S5{QkXSnYpfG+!R)My>Lz3Lgp*PrP8dwvYO=@8uPFee-$=6#j+ z$>nK{lU>{C18s+fwFIjGADArB=9p+^5lrC)vzO158$Chr1f*K-+du}Rj1R1XHZ&~+ za=Pbj%XM-FI|JXm67NhBD@dL{^B?cofjV~d!tIB57!|D2Z(AkOf$4aa%irCPcpNs1 zCYmvF!>ulwika%XO9wi0-w1zQaHoBJ{mMaxgAJ8>wv2oyE?O$4TjMx*aLd!7-1z>& z@(XuQ)X7iWphU-p@CY;l9UCO}t=_Sv!DE3WpjPC|n8z^*XPJzu--pVjvSQ?&uP$S; z9dpbAqyoDYuTPEkYo|VaWu2e(V$a8WCI2#>h#1RNiLj79uf8%(H|u%a4IDb2HNWhP zFCWG-iGkD|qnit@zezGuYQxyJWG)1Lm8~j_%m=!)Mm)Y{#WyeB3(+?G8=1)qx%=XI z`<-=jtaf)uzRwz)jemDz!5U}6lz+Pp|2J_`Zre^_-vZz{m5~gyzTwQiS!ZuI@p6us z9FJJ)Hr?^_q8+-@SGgY44)*&$_!{tk|lu*gBXm*HV5EC+4CdmpUNNm+x!%?fr-v!FI3QU;fY4sF@i^hq7Vemr`_4&3zn1i|d&PFoTUC$F z7LN_852?SraK!eIU+|!^WVA-7jh&eWAZElQ9>tvo>{T(Um|H5cf$&bsxkMoiz)4(c( z^kj5*_vJgC#hySW^MXSVIP5g)?BX5Bp~&gYLim<3%gg9~G|O-)@e5B&s4R|J*@eQsV1FVsl(xk|WmxbKQRm4C zaMCDVjPc!Q24Tk)`ciLaHy#7iT{n*k-+R%s%-@GO`yBlzscx_Xn?L`;ZMAv+LU9Gi zqff6Mt1A~+?4G^Mh$WT%FSxevzpz{rz;unVWP7%4Cth8hi231|R`=v$01zInP_vC4 z>U2r-f?~tpBjZ2Wf~`b!v}NF~&zYiiG#d`fgm-@(On`QD?cLna^x+-G{0914(4Ha+ z&ZqjQXSPqmdEI!tGMuchm_OELgeo694=LHZDX9X#GIPuH@}E+kFJ$C}k5K+>8lJy9 zgy2n(U1su4{z6;*hMTD%_~60l0VQm2yKh=6;$X9np19Z zrTs7J-ZHAKzFqf)(v}u2r9dHA8z{v!SddbRTXA=HcS&1Xpg07A6nA%mLn#v6J-CEo z!5#XXJkPt%TJJjhj6Kdi`_ulAF*5QYGZ}Nvd;b6TeOuc~KieVF6j;^0m2qy{w-0Omet8qw%$7@@W9cX+D}tv-N<)$wZf7+*WTaq6eUY3# zI=X%e3m%wnP$d{7_Y!z)!dVy6ul5?kHG6t*xh@Bw_6Iw)j=JlKxsmOe&t~r|x#d3& zw}w%((!v+Yw3DW^bYEZGvt0t&j9LiSzu3rxT;spg#VSR1VLg7ZQjc%;`{1#XYTBl& zVP!qp1vPm^DNU{1UQE&~!34ZOd$j3tvoLB*?aRT)@qqVI55Hp*T-*-w+#v{pd(2^4 zh~e}%_U=;YvB4*yJn^WzkZW~6j_hVl!p$tc;pcG;k#lKdGt$5?F@b>c$dX8=@A_?I zgTIQKkSxypyRC38Cv$&Klux?yAefr2%EDDpcJfvyu@xCSjR9c#g9Do|7Y$(|JPBAe zgG~1>XjK#YmiSyvIBNj#5|RRaX-Mfh=l)c}YQ=Gk!@0(k@|?W{=v`&)dF9gvx7!bl zIcGsgzg_I@Cw=5m(@gSFX9vg|Z5{1ZS4V5#ePbG@ebl`FekW`>)3cye&dahuAym{o z+i`0eyCNn2Gh0DgyZ}V|nKx=KWVo5te8s$Kr@g=Wifq)yZ2l6%iU-AR%sXhS$4yL6 z809t7S*e?Uu09rG;R+MSJ&j|IviW{)I(EX(8TeDjo;*$&|s4g8s~;#jG$_ zGMU5zDmvs>0Uj97cS0h0;UJC1^BzXltd;bd`k%eJ4MrxbOYqZq+1{nLh^ia*M&ml& z-^{G0{d?#$X=cWjTj+-@2S|Ku^xk5C_EMT(h~QsXzqBKT?RW5I_Hh;5^#IXXh9`=> z*SeA_7G-S;dy2IaaG@wANvgF0BLg#gJ#WBm_p_zwFC8eorMN+kEaRGhi)LNl{EMM! z&7L2ZOSKPPjvsB&6EpDN6EY1S>vbuewrFv8X#tCx(2}cTV{2Z>e8|kUF0(KD3)glz zqm7n~ZMwXsZlb(Ob|2_jl)YHx@*aDtdtjr9k3H@0o#OJ7D*?R5+ou?<)nQ#DWV%;x z=CGPmRMW66bqlf}e#;ne-H!irt}Zh|zEo#q?dQ3j9?z%_1-FVTLqmt4*WIS?VGS~X z??&nzGb*lwMJco!Kx!koC!W~tR6M7w;8R>NXXHa)F6uWNs6D5t#;Q73<-af_tEKFB z(1n2=2e~8C1OQU&FqQ?DM&6ozS*E{jcQ2tHdy_?O!fxrlTYl-9pvDzXo|`Xv7hBI6Zz7M1jwt;1EdI}u&t z$C{C6J$;&w&*ueRA$ym?fTKCb#)@vk+Fg~Ci)TUS zP$cK`3E`((i0x5o_#!om>!0Kp)jjDp`3ez|y=gp$yk z)Olq&%z}oHzOga5{~3LYnH(YQB}`YV@tCpF_i4y!hE)paS8>m=K70HK&}OC1BcTOU zPRAP*U$StyA4-&8fwL}QOtHO|!bIXn=LIP&4ct?$^1IBi5w)`0<)m;9ww$jC*f2l+ z_M7;jQQQw$&L;mo-E31aAfT~TeZJ;xt^+|u+Fhz2j4-+1a6X>zaqDd!?lVKew#Q0{ zZZ-w&3eAhaH^UVdsr|OBMXrPCN}vzQTk({-3c%!xOeWCJXF;!g`twBW?3XgtT=5|z zS@so<#!;$<%q*2JY7A&t?U-M#?wGS7mr+;^&gp7PqWxHybe$}xuOv%(fk4Z(H?_xd z-AtsunwAEa0U=;@qn)?ME%+s(dLe|PZ~f`_STC;C7L=OqJ`V&rx(P1 zOU9R8#K#-dq-Pm%s!Px3)HVGSlz3si6ez>|s0Rt>dN)79ATXNBELgP8vZd_~S1L}q zSLFB$D^Edz-BQD)rGt6%_1nL&Qu%M5Ay?dmHRM!=)iRQpex~Ahl};Z`wcV8tJC0k*Z`_1E&97cA zVS@104wH{WTW4%dyuV6Q`O%cf|GRE6;C-U5wt|El$kS7jzMvp#Qi*kBIGX>)L`hqYj9~@)uI-OR*4qO3P?*Bs}8Pq_Z7Hmf^=VJJ7sJX zVs529;=~(Zw${S1@koh%Vb8u}Nnd4yd@x_U;*9&0=%}ax&R}Y9blzh=Urv^*|AoS- z!GB?oAv|Ixl>3RNs4Gq!vxy*fmU44&D9&^-G&Y+Tvf}ErQZFm()%~Drn$+2H()Ac` zKFR{lHb@QMO70l}L=_y$(Kr$sFnK8vL=l-ICWy_aXgYT(qFO(?%5;Ztc1ss*_{=m~ zX(26Z87uR%a|@b;;?%om_xt9Sn)!SmE-YLmbvSjrzpV}RYQ$t?lAj+&4_i)STC@ep zw1)m#S+!%jHg7A)UObl_jQX?AoU+F=2o!79T|fQQdoWz>-mr-6cILHo{(Q3Mf-z>3 zLa`t`bwj}e#7FUTH^)+i{Y9m1fLbhF?I#xony@8OXA4za3(Z*Q>OR z&62n}23rrFi9O%jg7W-pp9%zujs{Z*gvLnEjzFWtZ1JSHY!?+*@-vo8}S`6Eb&M#2^{) zDPcl}(rH3hO}v9K1o^c?S`K0mJVQ}-5Rit3UF2i^p+|*-1K}eyb@iqb9Q6|%;PpEUK!kia=QVHd=ra>8O5SbKB4ZnW8qhLbGjCDX zy-ugA_2*~oQ$iX{)yN{sx5pdWh)NR+5g7zdma)>qLBoNTU-}u^C%!;<-n|O-#%*<; zb%NwN4$LMo^zQTz&bS_1yG+A~LBUhOG4c0(9Sz3O|*kAH0r$L_%zlpgpF6%F>iM$xE+Jp?lrcmC7 z`JWHgN>7xG)+?pnnv9%7f=PcJ3Vup`V+e@p2ihGB#dT&cAJ5-nB4U|L8y8;;CQ;kD zowo)E=~^D3`aQ)3RQJW6&YLXcmd?#2*(O>QE=um%n0;Y>yY}^@y+#gxWNs_k)byFD z&<gX3|q=8vwft*p)KMT#czm;8qMqA=s8DECo2Zsc#R~AD;su#ebCl>{|A7@3$^l z(-9e^>wC6O`eh35Oz9NB>_AYhDps*9^P$9qBpZc0N`4**pn*PvGBTUpU3Ss?cfd@} zYzF7?66gXZP&l)ew52sir>aj)J(^Q5y^_eN5qb3-+g!!HHf_0gSLAbQlr(Se((cjWW3fpKf)Qe z!Lku%=UvnJA-S5XX6bny5LP*)aS>Ca7a2?Jq3NFWFtL4;`$v1sxZP)>D9AhYiN$7rX()8BvVu+rXR2m-a;F^;? zxL7gsTFNz3?xO8I|6#yI^AX`O8%FI;6Q9JLcm}uM`aDYFH~3EMMI;2_6+H}0rt@*G zIqCPXoOOkmX66k3>u^gi!-S*89sZjO6O&PfPdnQFH$+N2gZzE<{_Wpvsjllari8Kw zNt=({f}7zJ|Au7TNXd|78C1=#sqf~OBr z;_v9LO5dIsRZ2xSUSgj@wSwM4de7M!ZAUa~7sXF#x@yBPuFpK5vPrZ3V!EhAN$^6*J{l+|~_pJ0r z{@~9C{PEzE^t)|H_-W=O;6fnZctYFxgkFuaHnWXt0VjOVsosF&dKwM^q{lqX3g2`* ztrMea?zd$7TvK=E;kX&MRhS#0N3`J*nBd4qQ1C+O+n4mRqwZI^T?9xcBO|2aq$AwO z+>b_Vz*FUhfBcW9{Mq@F)k*~`Z_Nms8JYquvjus@oc(K-+^=qY6Jx!z{2Z!d0zEKb zgLC<}E(7XaqOUv3rBRZQS$)lg9TlsBW443@{tL}2?Ie#3t)>GyeG|s?`%7GJ8^e^H z&|dqJswL2n>9>(Q_>%oWZ7Wfv=Lhb7+P|<=N?SRkR&zrSxYpG_q8Gf2D5G{ejgZcj z^Y^JHkIQ-wD|-VlEI&}R`A>icv6yVqAVwItZMNnfcrPQivat{0cfPvw+#aP8PS3vS zFEv-=lb`g0BfN8o+B*%ME$QRiDu4)cy~_tizn0x6y?KN3yDL^P3kqB*@dFi+I}P!> zMEi~#Rzb%eV9t-Xvx*htG`%%oPe7?1`&f8*vB1%qsmD7Z^xXdV)PC@rGu>T>=`Rk|{nOe%?6AX7U~SzK4uEUG=< z)nu6|^s2kMLTuv#*Gp)LSSsG^Pk0cmyNKH?Y{Rc7O>3c&&sAGhLuQw;MiMBQe7F#V zTRGaOEe^TP@=*UXbvoK3`4?8dWJ`z-)kwulVzN|kmz9x_6V|1;_9g%8ikIUZ(uc(4Wxo)`R~)q#x|~z#*U*qIbqNr6d2TX=1Zz%=i{6C; z>I)v)LxHRte3X_i<41)x67`F{l|7pVX;qyu~D(ugslZb#d zqG@aGl$z}b$G@<45=v)Z2Q6(U4JFYA5BmasN8caMQ=IdZ-dnPq_Y6Jf>qU=Vs*L%O zJ-IvEgXI|3+w1TWN;VB#?{a#Fr!HDNw3_xuiPd|mBsDg z5|DE#Lpefa36D)$QA_hw>WidH60Z1O-YUWs$9|@+$ZhK8Rb8}1xPb|`0rzc>;fuJA zf(c3Rrxlb)*8QWPPkfRo0 zNWEirMv%=VgFn^k)tjC9p6IA0Fu3Oa>9mIWgt~&Jw(5&#?-6orHLakyD#FEWQcUIv zL&i@|Y{9H4d*za2+{|52yz%o%&(=Xkf)>a1iSC&m>K_p1INW=pC)!mrj_-@7gCI|= zru0tREN_)rSxSkJIO@C4;pQ0N)aaTKU>z>)4Z1ajY%p^r@vP-j)&|cm3Ii zb>V=w+vz1|E(-dx<-_k%+qx0B?uN_!c)J~Q0XwiSh7>cZ@f_>8=qVJRiQv5HQzQ2E zFqh^s#O-9ifqc??m7AaQy}YQ{9)gw8?!q7V;gH7Yl4exB>LGR&7HfrXV9w8@p~8=% z@;K}nqnd6hcb|k!mGC$14c4Z+T;467EtVy&u&<1WuU8$I_wJsu1BZ{P9#c#$R}Ut( zKaNJp+2+~fuDN21CSD&GwP;uhQ77#;Z>#N8lA&sLbY`w<-IX{+PC}rEV92sBRz{&o zo66*h9Py(Y{g8ztHabd74`2TSVPRombl1xj)9FJof%Z$9#qEp=SB6TKGS!8sWhA)@ zqb8_s`rRP3_~-F@;kHe3l_1H;%;~cyK$kYTh8u=D@`w+A85&k+4gS8Sqtw1 z85lIWKZymh5(o_9U9UzqmATVmA+vl>-d}sK-0d51ICm6nBkux3hB5Y&=+^5E8TC4G zU66yF^mjd{nK|^C>FgY9`F`GW?fPz?eS4!-g*p8ZKPQy)RAb^I-RZmEh4;nVnc<<3 zzp&!VF@^vsYr@7n4|Pkbox}xT2>gdKkF`Iv(Kx2_tzwlkh%}OQSgW70JL`WB4*0!sPm^`QG7_nt&RW8 z$>loCcI2Cmr?kG3$aRpj+<_39N1BFR6u20GXOrevo%XrH3rQh_2>fY7MKng-(}*w9!63k1shu)3Z1jvw}itBMC-&K509mPxyZ*Ce^BJtYJGDH3-^ z+mY`_Lu7c;QwZ}fvbblFY+*k6hX7?(!h;R=e(f+j#MpMYD!P-X()H&3+zLvbfY|B+4-Eu?+FO@6-T`ITWESC z0Mg+-D%R$A?OQ=IwCkalslnsRGxCZMSYvpI1=fzc|; zDLTCr1;W0C?<;pn@oVRl{DVuB2wBZ|4(0@-^9V<}sus_5gx(K|86VApIPRj_tSQ4c+aDmbyKaw8M3a66dyG z43J*+Qrv`==6*hPXeQIc$8}MCxMl*>$VmRmuynV+BJy0ShPQ3@;9DKEEe^i>XWQPlkk)8rkw)RPlab|1cXe<_7hKILi1*ej9n?hgcw^A? z4?gT1$y&b7`No@|+2By80ijONRDPXt4tgZwhU-^LEi9&nI>)(EB0TYRlIy{E)QpO% z@c9MuugU`hC+-kkft6L=*Txb6oe{5}hSknt21m0kMfNIY-Y>Oac5ifrbLSHI=|WtL zi()4~1jud2+bEv9(_s-Dc zW_9c>s1$}GUSWH10=T1q#7pfgpzllWrF80FN8%4CACyfTDS9TZi5Z+Qp@AR0l%^Wy?9~4 zYbIK1pA~+&jibwJzAX@vbIo7JeK2uD`Goq%(^0h9xsrRe8dY)8uB(#GaD4Sbj>`2w(1r!QCL$NA}_sg||R z38GEvSAEw?d0Egh!^ldd`WVi9cDCJ_v37w^`f|XId+TW^b!AuYO{H@nS}ySR@=k;k za$T-vC>3t7%LR5M?8R8Esb_BFuZJ{F1{>!@SHFzI?}IG+8vr8()2|014jkiCa|`EI0Ji-bAj;I+-JQzXA!S|Ro(fWKf2iS=zIN4i8cG0 z`~4`7x64k4wv(@<--yC)^C4W+nW<-RQq-u}s(RL;KH%#3`~bbj#lLg(J7NbMAH=uUdIWHh> zRuWea&UZ7vKtFfT<6x5dpt&6~5a`6{JTm$~>}aw608QL6fQ&WQLF?PY`V)27XMcFC zz|{Lupc{Ou}#af$sWF9d}%cuhXm!<7bp2=@m(?P0sXBkVl4w|wC-{#<0x9Efft6-J4+QBv*GF z;%L6lgX2Y%!%i->HX&LIZzG{&nwW$lO&}nk7lrm36pC&cF)| zI}*lpOc(c99zj<$P!Z)Ke!azVKCWLrw=8O%KF$Aw5FTzV5I3Q&iXg=hJ*6| zYZm!wMp%%^6qal2zYe$!5c!C?!dXHx`0P2U?_}+*!2G}zRNWLgRra8^bL}{P2us0z z*~r5-$iW{8Z|2iSjzo{INC(b?-`4M16>XgJmy5!O2KutgzK<&e#f7HCNh2H%wHF^; zKf(U#))3h5oPQOA*DA1DKh$j2{3>KjtJgQTh`eYkp7+>3kBD7dP+yf)5ojQ!o8$L+ zzWinW3JS|Ft>e27bGGISgihTiUb38!R00-*^TXRsabc)U*Skls!)>}_XnB_(BqE)T z+4XuL(&OZZUfb(dX?x3X2j*$5GPOxJ+rpAOL{?%VS(&#aX+a!1wtgu>cW{UZ;o@az z`MYgQ{sxWTSzt#t3uVeRp-KF9w=Pc*sB<T(nywC1{e8{7S`Kk3wYOFQqtnT+7x1&w9*N{hfBwz; z3!BDCJ~zP(WdGx??IXN%PgTQxFa3L7n@YGJAQ4Q<;Gmf>l>T}388y|>J22^Nhgz`_ z_R&&4@oc(d_gQ=DyBqs@cVo8z=Z8JcVLpkR*X{6GJ_z%{Rpl5XBP07G5jH+PzWu_2 z!l-IV(#M!V*e9wnjHjCTXF05l_gS^3-#L`5R)5!*APB_R{?cL#?6hpfM-$yu@JIgW zD>T1D>i_hb7Whw1gV6k2O+#yP{Hvy=2`#-aw*rkaZVI^h?TaX_qjS<*)pxrL)$wHq z(STr}1f2ooM!S8tQ@8Mn@mGo7?j0*#adA3I&MI{llU?bG-WD2SfF3jFS64QQvU}s* zD5DymxdNQJX*zGl^gqkz=H@j_^hio55Q6v!NO}mkLu3FlR`nRRtr!+_NetCJ^GB;{ zS0zpL1iWlZ*!TzT!oiN<-Cx`r@YjCsPw&)@gwG+iqC#5eGm%NuI*oM6>patVsy9E4 zJ3ifJaUK&GN&TXIcOrf(db8g~FmM#f3(m4E73eOTfAqrnm{P?fE4+li;~?0=7Gtw1 zDX-OKYuu{A=Mc5YsYe#;_djAJ1Jj4RIcy&`$Fj!&hp@8q?J(Mz|Dc?Fz5F0Lj*CbF z5Mk3oW;o*|`}O)i?XZf!-MN8%MDGsAORTH%m5$mDNs>`WDue~G`wsWA{$uG= z`3G^*7V-`J4{}uFAO7f=n!?J_Hz}6#fAq8(QUw3$ z=otj>6qyu{jdVp8Nxfb(ma@w#ZeHBY&CkvKembQgkJed_%1RO%5yirKE}!wlg@{Hn zWTVjKQv`bCRy&AczNqlluynTp&SGyY_3HBG z1JoD)2W!W8yp1bfahNTQDAXies<}NJ(=_s^*Obwg(IsUn%%E_u(ODF*Ux3KCNIWDv z(T{q_H9e)E`z$*2$Bf|+ad0h8U(iyRDbd{dTrldH#+yzIPvxzX>J{v!u|$swLHBgA zC;NcdY1I=@-`NFujaNJet$LXG>0kCey6&%n-nMkI635{5QO(Q56`R9ujm8rC^b^Fx zp~NxquEa7ISMv+p#GhUeuu!4-tQm3Ylkvd0ztsqD5RTW%D>TjYSK%QQ*H&4HRWnPr zui{R-6lcV=LMbi5ABI~_MPfXBuUYP67BbRryd;jvK)qYeb!t?%`4$=p!vPqMfj2A8 z@=>ntd`WnL??e!2S{jAT6Ktw) z7;os*+2Lf31A4LIP8M^Frp1E`yQk;|yS}2|J31B_zUc#fKaO2RzZ74`{+ko-A0{c} zf3DqC@+&?nx_g}IXWnoJ?D(z8%@)*NIm@yFRNi-P9L@6m1M0}%IN#>1Gf$tAkEzzg z=~qY0m3-c8v-(f`$^Rt31S7bG;KtGnh&8wDYz+G))+#Ot^H{EzSS;+i?2-mq-=9=|Q^Wx|d&*1nn)$DJqOf+h6wX zMMeE_?9;n71ESX6 z-gK9q6H(B&9e!1SlejS>v@#^aj5_w-2C!wPnkFuAwce6+9TL^uLeGb+uxFqK!=vBHeDOPg1#LM!hCn(C>^A&1jsE^CCjoNFHk+= zZ&l=l_6PC(5poa?XFvB+x zd$<8lcW;-97Yk+S+RCqQ)dL1yfh+4i%>heLMl5qu`tcX2?I#?^ZYDPJ}T4)VW zJI*24*>5rln;^P&l{|(8@pcO3A3l<34xJdj<&j}k2-Qle|LFlx{b);+t!{ocRSzqP z_|}%h@_i+G6zW508px*F1Gf7139CN^cqcllE24TBtyx_+sl$BEAKqRu8@^<-^oW`Q zPkQ>(jFtr0$ZQL0GSiQCKsovPrh3ztiQ~@_6xfr&Y9Y+E!ZqiyNL$vo2biQMe1dvU zx0)y2C>g971SLbGbFJL&Mqj8!+J=1n>y5mZ(!}PDtQF)6@AAq$yn#5m<(AK)DRr3v z!u$i&2EG9*@hAsxU2@h!A;Rz%4Gs{K=ben@#E(KKW5nGaXv$ClG&wpG3wPCiC^Tv_140<4QiB%}939 zk;z9FIbVQAS7r{HifQO;0O|Z&>dh8g-`+zTU0S9RjV3L7*Dj<XUGDV_t$kEjaWo1E_=X@vHZCkl;;7 zzb=fdKzrX@DEO_XF>tVMbwX;d{}X10~hyZ!=CFGek?bqwb|rD$AZ~~<{ydT z9L##XnreQn{QWP1#W0i;VZOOO>$UExAOxHy`wruQL9i4TuZsiVf@&+=$a+EtPsQAf zv}R@zCV2kQbVo|X{P<-37h78K-A!AZzy(|@V5$u)TXWqEO0yY%YUt9Yt)(e^&T3)# zAzt3G26RFJs_sU7((66twK{RE{T_awGmmU*b@WgqG31fuOgxtGAn;rk(C=1(n71k| zAY{}Cet@l7pGiC_$MIR$WuOcam5CppP*G7bqGJSGAybZVo64YtN*ObuF`JFf(}v?7 zQ}bevl4neP3=2vq-ApaM@|-(nTjk#!V~P2S`$9T((ltbcihu+wcdt%bY*J zq|aGsx9sgzTaIrp=Wgt3xJMBQJW#R#iURyqcvKjTThF ze$DVn#XZlH4%&*65()|ga3eSNrkn8-`l*fWqZZr;`jZtG>bb)Cgnwbt#xK`~e z@C7~EPMm4XZ5kEe=BY*}h{YX!c(>Fov(~aCGMGKiXIlW0Q9LE61qrY~6dVLtgqUg- zB-Pnb@>&a&N;>m)^Si6$LzeauxRu=V6?34xH8&Hz;n2y=CVLBlrmMO{uM#`I9Tt0IJ<`Bq6*kZV0ZM8Ez%=s7f{apShlCQ9JzJ>4A;@QhJM`nyR%u?tr1peiVQFcs zSVM2aC~??*^dTza=`;zljpcapat_w{$GH)Yq}#SHRfCN_NndFu5#CxKG~qAr~G6=JfH# zXL=JN#%0_~tEKcUDA^UG8t}iWRdOFeGJkH&1|_(E@Rd2bWrrwAtFPss5jfl>bfc3sYn>A&`5z4okDCZY}^^5equOAXY^kr=!Cmgau#&iD_HsFl-l)l3IpFc zWB@a^+oH^`GYm&Anj+2d4b~@SKzR;hkL@Bxo%A>bqj_Wf#gY7ac9oX0J2p+J`l~|Y z%>r&Gabwjz`E6m~XT_G2L$s~{YeQp!s8pJe|xmCcWZO%$sHe!g>y|vf^<##ika4pa@=R`n62DcF zNs#DRsHp+#aNHs+mFh(F;pCIUsoBh8KOo_w7knBTv~>5hnehd7@kdMdCp%UguWlPs zqQFiyEi*I`o0?+=m6&X}-EfGXUHBU@D|crVu%y-ZE=*(Y@qr64OZ2An*PAFdEj9p2 zZ`rvPKKmC~#+Sy{>0c7i8T{mzhtFD*#J#Xqd@?<-gb5RVc%_?Ml-Mx89a zdq90MJJA43M>fVQI{sejpJR(6ecuzYItQcJ1Cm#(BfptChrdG2)we)SxZFtxdQZWL zQS;>}Fm`jz7L`o+N_B(z#V(b;oFzMmMr2%#$MnUE)2C(;arZ(vm!kLv0!tOPb7f1U zl7>2Gq)G=cfN1JY%)~OCwa0{%s`?XCPFUVl$6{ni$kQ0Gjszh+$!u}Q(%B?VJJNRl z(!eO(Pgt%n<0af(hz`P=wgYFUKX~OHf$U&y*v0FQl)&ODd8Ix>CxBuHk+{od?A&8HGK_6^3~9Cc4H$3E%Mtplain*4%-{ znNmuu^u@0UYzOYJ9H*sE4vzNkXy38M{};$U3?sk$Ck){KMS~~*Um`pOpGK!ZY2cV= zY)nk7+HS>z@HFnPlo_JqM|5O%VM))|<(P^Y{b)Fe4!Z|`Pi7d6>*AG$HmqvImNfbY zJ^BqwBF4t1$K&#rmmc~cbvn`st;QCEu3{5F@%dioOBq2l=$)(A}p(-m8R%0v+dUwK)6Ah_9`*yD~kPQOt|jNBc_yqjtg^H0Iij>NjD`E5II5BvH?_-$4_Yx-;Sn{E? z{2K&Bc}0ms_6FFl98sZZUUM-Z*VR$BK;z4UGmO8nHQrF*H9KW-YPEHOc@x?CD3OA5 zwf>IN+&x7d%UfNp{E!N&(2+~rXEcW&h8qs1tri55jd&Hk+S|8{6+8>_n=nYeHnj<1 z7@(e22Z0wNz3xRl5)d%V7|op|kgd&;DZ+J?gm6B*|K87jRy*}U4*=zU^2$=DJzpbS zQM$x!*-7`+T!4z8?)z?#@&232!!bxQd4QT_2G8BPS|!h^f$-mEgud-N($0*fEJ9W%AJIOiIW@Jpt}1 zN5{%pKs40W!AFEkpL+njKTY`%DmKk{$=1g{WADMhFW-DqAb~HpkH7p5vUp9jE6w`Dv}w9& zHh`10&bTNUEi8M1cR)cQ^r=%cf zxOU&KQ&mTxFl5UuJpClz~!(;jWM-FQ&R?PYGzS@qHMR=AlgrHFvBg$et&x(oKJvY@lReme8x* z`G?~!EiYDJ#c`a1L8F1Stz`K9ZT|9(oTL;Trzk{8aXM0Z0A2b^;v+ZTQwAE^>vJf z)axbkw;*eU^n}0<0;<)}*wZVcK?FRy+UL}q=kn|j>+*-lw2jkE+b`|Y;xgf_&tKNJ|Qcm_+VDhj=_Yc-=11|Mcjga zfF$s6j7@sJbV1!8da(ZT0Ub6JcjGl=&=66YsIBDJd!*>LWz%Ff+bZ3DyO&VmfRbA) zj(&6H&fSy$LlhYmW6V8I>R1u+;vUA=xJ9RmqgtI=Ldx`Qzr(I;&xmQkCjfa0Vd@yF zsZ%8rwd`GT_5|DS?G#5D3>OxXD$d>68aySbO^&vHMl?*L+@n{fizZCap4Nj@r;YS@ zm*=d^*cNecO({G^n2*c|!p?VQhaa7j3nkepXPD69PLH^oB8IgO9&EXUrwwkkNcJ%V zY~-{dE421{2q+Y!0zsZ+>JMWzCgtbN5<_cKFXE}iTs~^Np7Bepe3_AI!W`~eUWCJh=iU4znYVI1JL8+%=@DvC z4QYZc<}XozR{u5`qoN;%apsAs(87e5t-5Va-Z7mqioRye{cc~?WW9k_gTn(GjX)E* zMY=DgJ*U(P0&st8!u|v81tPOi-3CpB>PweDPu-u+-62Zwf3c};m+Fb@TJ)uaErWnB zv)g4?dyyp^RWhnW{sOTrE-JC2TH=Xzv=Uact;9;U0Ga*B_)*`Aa?Y@#Ajy z#}A=fn=tHtXuy^+WTH54zXT)KN>%RbkFZEEqF#sd-`4n7BKue9x^Bot1yV=t=`9s` zB$)dBy>7%jgSEOCMMl4<)+T$2m;1FbaaPhPol;*9dw=}*>4xUa%>Oq#62z>jmsrdg z6yLs*V8AATPiT>tP_N@vcnP@8Df^-Be0IrPcBndakFnK#?6Ltw{ogJT-;qAFunp0Z%JMvJ88W}Soe zijpTL4ll}leWK!i&D2qd_KdhLHXh7jksE#Yb*~u=_zNquPWNjBk$vbGsxc5rR9p-T z$v9BH@gBw)s~{71xfBWuOIX?>1dSr@nKRYuEC^io&J^G(dmxBhthgmFp5a|D5vFLREE z%TJ)WB=B3A>+kXQ&3HLR3m+>=so)8%g>tfbPPmFbyCoxaYkmS@?5WrjSwL9RVA@Rj zwQnEoZ!C)^6$VY)peMFm&{h%>dkYLx;+4q1tuKtK3AJyZmfoJxp&r$S-X#BVjW%Lc zRu70R&Bi<>7+8L5H*ST`X&PWQ%z585yO2joMA+D`ApfgQ%{foWFS@TrzZ&&*Wip>~tm!-{r=8k^Max7Y`MjmLTMWsl zLHubUP=-{YgA+c`*i22<ZUgHtGsX1gknv)^1a;`1KWxdU9TWE>^3GeGmz{# z+8ndg*@P8NI5hDvF@`+K`VjYUpZ7c=m!V%}PMh7Er-d*_5Q3-mm(5~RVBeU*4LZ7 z<#cEx>ic6B!r1O9Aj5U9$=}>^iiY2oN6QR4w9N0VNEu2SL9k+Zv^=+5aJ*tA<~+1o zfuWB4Ki$Gk{Y5DJ(n6;&d=RH3K;E<`s4Qj3eFeO%g7G|+SMi+@+RSG?~)QGT5dzJUuIB}Mhl;>Mag3408r28^f4M|_V6q0;`74ILf#rd_>PMP@^3bz=< zSunDbYqYqqrgj3Vi)xv5`TA$}P zT8q?<8$EcsBKWovtlrW1)`x_xT>_OIj+3l21hepMU@x1NI=kfiiUY0UWQ$Pgb8Hxi zKbE2>7V^7>CnKYr7#Lq#2yQ<^vOEq7@8Bky%<|rL`vSdh7Y|Rp;d7YPTD7_|4OwmJ zeXMWpjEE4KPgZbvW327}xzfllWh}VP0%prM$L2$wHGh(?arcLpM>|KauUhL_Qgw$1 z6ed*CS+Yvk7CmZb1c-r5zY-kk$KK*U1w}G3p7dZUWkH^!@i!zo`vYn$vY!=ITL+d2 zt(XChqY@+N?C&{3O_xYLzT@StlCr*fbZrkz)9#BkkpP58GKE@Nf#zi@jBCk;?{}Dc z&j^=@?}F(fX%($Ee7@adq~5C6{M%`jvG>o)eET{x?V`X?U|>FVx&o`!l)uVb@v{1% zZFIutEiSj`$h)(~5t|eq558q_Mt#?~(n}s_3G|3f$FqR-^(}T>%gPG#U(N|7QXAJO zdUUilwfoMM=!YoC`oh9O6YK%ZFluJ4oyiraAWI)ZWuq_-P5Rc( zA(#48jSNs|u?0l~FpvLHsk2%+YMI=!Fx^4Grw0@_Y+2z)uWxCW3a^Qk_ug$7xZzE> zf7=ViL+hz?l-dTfqZ@%HKK1`S?AwNZvS>*jywmc{w~kAnz6W1&M#`SJ-a!~xRDd6HYJ)VN3^=XCwLKeF(tYlK`x#?p=OJh$#Cy}Jq1&%_ z({SR)Mrp0tCWuPS{Q}zIB&vgK09PH7yg;z#ro0ZL38)mxwmUA&Ts3|ax_Z~a(W}Pr z=L2%XmN`$Dkh_qCd%&%GeU6!VZ0!)NeKF+8W;$s*1i#5c^hmk3#ZRgRQP`4Y9W{QG zfXlY~ycHx{uGlR|C7~y47FAP}&r5@XByWikukG04ywXeC&uL}iJ_hqA3M;p^*seh6 zmLYYvJ_HH!6J^NtZC=vn5an$?(qM?|HXo4ghzNQVHMTl2`$39-$`#7zku&BFD=PIh zd<;wRhN8)T!cu%aZDf7rD<(9HE_CK@dJ?_E_F_!VwqS;`74%cgB|kzQOg#feJGMq9 zeU6=4h2j-$dLC7_9X@IJEJKs!!Fd6RpdrEdDbbdH_AbVAAx&B+`lbT=#OUbDQ-Mr* zKaO42kL?}|v)hhHJI0J&27EMs59MolVgk1lHz}y=?3$tRDcs2N9++WE#6rml$rM&B z)aG5Gj?%O4wHJ&oB{ebOqTi9H-D|LSC1w4IBS9W8pmZ2sImb~0QH$eEfN-bBerxQg zIqzao`q(u?40C~JBxx{L+#tC)fST~ESF06bAuSxQxa0A*<@520cFRqA#28J+{nZ}( zm1{o&e5}B(FE2DwMb|Byh^?*2B;mYcUXKX&2nc$t*4A@<)b)y-Iz`fiOaFZT)tBbE zPgK%e>78_s^5uJH3`H_JCQ#gRMV+~jq&QZuJ@jZ{>E@6`xYWvAT4*PZ05njTZ(kX2 zYqOi=Hcnok)9>Rh$olA?|7l!zf#dQwxSCk?BWA8uR^YPdWsGMsCRe%`sk*!2d zG?a!u-I+F_sWZ0q?>WpAx+iJTSqZOhB%V@i z1NA8{vxf8}Cs2HP9xutqclR5gTP*4;lPoTkf!6KraF5F~r}T1GDJBKtS~AI{*n0p_lOi+v<<_(?CJ*&wny&=!RLaa{bXBZKD8WQ^ zi_#Ll4nGezbJ_xc0_9=zgHaXna&Ym~E=7hE)n`gr_8g`)+%<)@dU{OK9|)B*JZc(Wbi}Hw7P7e`IV(kN2QL*@7uB-PEwu{Y)s9UYg!Ru`M*e_ z@trYxPTE&w)B@T)jd52HCL*RUR98k22m`}wi-KO&K6EXFZ_;>VDCSitPi0AEG_*_* zVM>6Yi$1a;mL%zs(P>SDGZ#Y#_6#g%$~a@)38TLR>{1MGrx;oQlwaDmfso2nrLWp5 z$t%n*cb%Lkg-@t|QDo(J&XdTwVg`%U)@^>b&OL}10%y4G1eds0DvrfxJo&bCBe)u3 zb4YXwM@M$J7bdFRIHQL|AKoP+Zl^$+57~*F8QkGh(nE;C(M_Tane~wgX%nRWNd3~W z5$LPJ5RI=I#PXCMNY&=CH{e+_9QIe&a+W`Pov`G~A#YVr7kU`gT%QSSfzIau4+>H| zQ38tcn;ZtbpZohIe-IJC*YZ=XHvMjxdMW@FM&g-?(YbJB2PE&KjxFLnE!7 zn9V)xXi#6$I@=`VhrM5nNLYN1AD;I*=b3 zn79)z&N|&~54a^ikTG3g<9UN0!m zE4FPiF9V(AiuyaTYA|8>$F>_C!bj|x)ZMU9ZfJ}4uDiV@tMg(5M&q_^@z&hHL)wN7 zVPPlvR}C+}3MO}#BOk5UGE$PuC93$+f6+DLE_x&NaztfO6hg`ua1g}If>mczdDMo! z(_i~TRShyk%8~r-g6vaCAS~kKQ_bERmyZ+L)^kL2m%Bn)N~4(FSnq8!@64swmY3fb z9o;l}(2Nl@{#-i0amc{MU&%4|GOh6fDJ#u?1Fl1q$(YDn2*DmE?GU|-jF7t*uV1oO zzFnK87*TTwnHd)aEOHACG$#$akBuaxkH+GN;o#M1V~K|t^>10NaNDW*)#-EM9oj;lbELh)tu(^)h5|P#EPdx|X;3(8 zmk6Go;+qxqz3z?Zj23b3Ss8w=lHgK~!|4zeiIIB^S($Iy<{;I~g;I2y_8nD7O15M` z1{>(%mpR&Wm0y6JD}nXp(90e6NX*l1lPZ_i1D-S0{c=ukVCl*CQ7LK`cSq`4(5_(p zO|->^arEWOGKHm)#%9@M^Y5Z!g*cs7}!x`4`McB!=n0gk$@-8ZJL6{-G@#&)7*FFM`K zA=lJd!3O1grqDW2wR^fu<$A)S&N2Stt8vGlk?8&oOU{Y6OlFDBwK!;D{| zz$T+-%h4dh<|pJA$*o{xng*pEUVmfi2s^{Du?T|h$=eN6#id^)T_uQT{7V0PI4Umf z|Lu~D=?}uTD_mY>*V>X1y$}$3FXc)7;-%p0MA-7XKkQzRPq^GA~)@6OC9(+`Bh1 ztJ&fI0-X}I$7wTEq|%V9{1gzA)4TuM;6m(-r3Jgk41&!)>WV#@MlE3-7Tm^1KN+&F z&+OxfWVgpu$Ln^EcYi+ZQ{tbaXgD@bFF8fz4tgHGZbyO>bc2egD&tbZb2n@0kL&wa9FGF06z4B*Mz861cEz3c7#S%QM9+|wq`k$R9?rD>3n-UTMd zs?{egFouJ?(N7NNSU-sR_`D^Re{qez$=F6R0O4W6kh8-#8T-VZv5Re@$+b?>0G(d9 zMY168KNe|#qN|sHqpo4kMfm2Aq5PZlSAuc_N6gry&=eCcF1%%>%V@oTL|sajRi>eU z@WN{OMyO01CC7aQmaj&}ph_u0MPM<-lpw`gkY*8zjM973Zo4h=Y+1T&__%V_{nSR0 z$!DWy5NyqnSj@SuyrZ`ChUhe+pa z!{abf%@#?tfg1~HLsX5A#&nX;9)WHr zIl7crN3u8)w6`zI^JA?Qw2_nCD()LVdY)`X(yrpel9$uFQg=SKxhCk3*N2tetyi~{ z37fgD#Amt#=o~`>EuhTS1*Y7a_o@bKh_2sfEj4qRZjr1AE(XIN5?hKI8$`f&mt9Du z&rE}`kf+TTMhApq^){*F@JZz0t086Es;*7?+UZcMjbsCmvomkLkaLF1+Xy5e5*%^c z*7VVvz;EOCszl)?Z=I>qG;T99QmF*CjDyO(Co7=ot~Rl;ucm8Q`M_hgmyH4N9mxX39-7=py3%RBxJyG9B|7n)+?i8yW{qWh*zG(xh)I& z@BGi^=PpIjbZLNp@_F$kX6|EG+rQxiIJ%Xg>XO?H1&lJdABKTB=vhHjmRUlS zbGD9Vq(&Sjb$qs<$pAMs#dNoOegt#Fr>NNNesdo)LyJUBW=}l-bIrE?_kGSy_=vSTA`vUI;6QUFbi{QRQ7lGm8NzW z>YyJ*q$<%_dMerk@V_F-eInWoeCW?eDz`sDMS4zJ6VU#}7vnITXPW(h(wbNi}& zPmgsN)({Bfdg}RLg)vPMS2Cert0K5Lif7kpSUzGO2v)PV3@x2A=ax_W(r-YSp9&wG zzYL)enCsP9d#ud^JfHK+)9S6OUXfjoz+dPbdn|-fsA&msX#y%oryYY?ujsr1$1@@| zBeH*ynC|cFQJc#!u+&6EH3iLueJD#1?k>Ox)nn~OuYbVb+_#kM>REeLaZ6Mtu2x}J zjOn4RThu%17ne-asG(O>?qD|S(px|vl{bUlX`D4ihWDM^f&NOjaDQskS2Zse8WXz? zWFj0QkUuwGDaSaPKAr?2EZwkcZXj|c{o^vDo_nlvwjz#PL=yX(Yi+lsXdE^aSXL5O z!r|pSnj+A$!qW8&dqc34?Xc0HBaw4$t*xnTpl{_+n%K*gtj%|=Yn@3{7yRo&T4g2fICfAmj? z(CGFU^1A!p6+cV471~Q{y^H5e0|#aWz>Xd*yWrAppso`p@KMdkzZYKw$2^CjrN+3~ zbVI~Hf01#9{dsO*;u`olHEFw`$52~#{$Rq4*HrL#!RVS{!5@AS`&v%?&I@j_ zNmUufKgVdud0Xd_DygRH-|b$SVJe+*uC3X#ep(Om@E*kxjLn!ijWi`CJl#JV-<4;o zc3Zhb3M~0|;MmdU)Rp7#QtZ%aJH&)=RdIGTy6Dz1=)Jum6%e^rGP@r+cs;CNBo=i} zBkBAkA;{V&u-V9x5NKXt-{UIe?t7&k9&Xm?8|PzZ+t3}CEWwCYZ1hBdwc~^{oe*W( zmCtIzTcG(YX3}5uB#}V@{LGX&?0fh~!ci7LX4ZfD>HbXX0G=*v(Y7RI`1-0X24nk! z&&6HB0W>kRJ$-r!+AQtLl)4>i&6GUVtQWQvbM&$B*;Lg&oyoyBr9Mi&{L^lxT|lb? z@h=_V?x~yuMew}tnerXafiS-iE3n>}=XbBO3e^0jUVXQ^ zTrum?2X-u(8*zlCha^#PL+)SXh6rYIK?awv{F|6Wni$QR4@-Trkk%KoIM~!?S}iRG?-ro2PKUtRWSrWCDT7oP#@CoR*z~*bfdv`S zipFwn{+*6{5dV50an_qn#mqg>Xjg2c*Z;Ppm6uIk{mfb=SY{!9$h#=sbkc}2{gUL9 zNFC+aH(8G#-*~Y=zNE^wk?J&1HZuHmDll%!%PmvqZkf;(YQ#(x1ZUS|QSySe+w6AH zJ58O>om~^8RK8K*U2@4XM@M6!hXbM(u&YHC2;0vGG3exIVEqwD9${Dy%GmFtRAG@P z4<7)rs79sFuKQEUsj_t^Y+;g5wO95_<$)_B4?U5+@=}e$l6pe!&1vSoXK?nOFsFR{ zf!OsJvsoYdu-&W;d22f4EOO`(Ck=YB@H96+DGaSf_L6c+k=|T zJ`hAsH1T7cUciD$3K3E-nb!5Ae6~XGfa#2D&m{r%aa*8=XBQNbpf%hho0i>lOA+jA za@ob$puUBm(32Ho&7)`mvFhal!fy%j|Ic(A;%>f?n*ZQwrHFLW+_%GW??JXZJ7srH zZWO>WN|mqBrlF2#Zf+}GNHeRr`h`?rKiOwymT2P@wQlWHy!I_$)S&k1iY}Nlt90L= zvTqe%#WRVi^K7FEG!(DaD`uYH#om^UYta&&xj)B!o$%_Poe5CBM0oQXFiT5h&AvEL zCH^jKB$C7G!TLkA3FtqUhG?1j{+f8+`g`U{>v#eAU#aR4eec9uN%|o}iGWPPY&(PQ z_X)+xUnH?%1!~bFlhvWMzeuj1YW}NGRpsw8;2(uF4z2rxC$@V$wKhK;&qR-tt}0|w z;FPu#MsxZjHgXR!c-8y+Zk%qvNII4DgQaMYz#?Y}E!p2y{YNzc|8rISM>PTe_xN)E z3|ZNoeKEzs(n89BKAMPv+C2M^gTfN9i%4mSdyX-@{6Sm$D0IIdBp= zxv%Z3UyzWvQP|KjrH-3#OU!l58_r4U546&HOR1^IU0}Jzz#2l4VvD{-`)l%l0gc^n ACjbBd delta 62623 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 z�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