Skip to content

m2: DB + schemas + migrations - #304

Merged
h4yfans merged 25 commits into
mainfrom
m2/db-schemas-migrations
Apr 25, 2026
Merged

m2: DB + schemas + migrations#304
h4yfans merged 25 commits into
mainfrom
m2/db-schemas-migrations

Conversation

@h4yfans

@h4yfans h4yfans commented Apr 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • rusqlite wired into Tauri backend with Mutex<Connection> + WAL PRAGMAs
  • 29 Electron data DB migrations ported to src-tauri/migrations/*.sql
  • Custom migration runner with schema_migrations bookkeeping
  • 22 per-table Rust structs with specta::Type derive
  • First real IPC slice: settings_get / settings_set / settings_list
  • Shell-neutral notify_flush_done real Rust shim (full lifecycle deferred to M8.0)
  • MEMRY_DEVICE=A/B profile support + dev:a / dev:b scripts
  • Dev tooling: db:reset, db:new-migration, db:schema-diff, command:parity
  • Renderer logger swapped from electron-log/renderer to a Tauri-safe wrapper
  • Updater mocks renamed to match useAppUpdater (updater_get_state, updater_check_for_updates, updater_download_update, updater_quit_and_install)
  • Bench binary: 1000-row list p50 < 20ms (acceptance gate)

Parent spec: docs/superpowers/specs/2026-04-24-electron-to-tauri-full-migration-design.md
Plan: docs/superpowers/plans/2026-04-25-m2-db-schemas-migrations.md

Test plan

All scripts live under apps/desktop-tauri/package.json — run from the repo
root with --filter @memry/desktop-tauri. Root-level pnpm dev launches
the Electron app (apps/desktop); use the filter prefix or cd apps/desktop-tauri for every command in this section.

  • pnpm --filter @memry/desktop-tauri cargo:check && pnpm --filter @memry/desktop-tauri cargo:clippy && pnpm --filter @memry/desktop-tauri cargo:test green (32 Rust tests passing locally)
  • pnpm --filter @memry/desktop-tauri bindings:check clean (no drift)
  • pnpm --filter @memry/desktop-tauri command:parity clean (89 commands classified, M2 invariants enforced)
  • pnpm --filter @memry/desktop-tauri port:audit reports 0 Electron-era hits
  • pnpm --filter @memry/desktop-tauri test green (3589 TS tests)
  • pnpm --filter @memry/desktop-tauri typecheck && pnpm --filter @memry/desktop-tauri lint clean
  • Cold-start migration: pnpm --filter @memry/desktop-tauri db:reset then pnpm --filter @memry/desktop-tauri dev:a creates all 29 rows in schema_migrations
  • Bench: cd apps/desktop-tauri/src-tauri && cargo run --release --bin bench_m2 --features test-helpers p50 < 20ms (local: 285µs / 75× headroom)
  • Renderer settings toggle round-trips through SQLite (manual smoke)
  • Schema diff against Electron data DB shows only expected drifts (Drizzle vs custom bookkeeping; FTS5 virtual tables runtime-created in Electron, deferred to M7)

Schema parity vs Electron data DB

Ran pnpm db:schema-diff <electron-vault>/.memry/data.db <tauri-data.db>:

  • Tables only in Electron: __drizzle_migrations, fts_tasks*, fts_inbox*
  • Tables only in Tauri: schema_migrations
  • Column / index diffs on shared tables: none

Triage:

  • __drizzle_migrationsschema_migrations — equivalent migration bookkeeping; expected drift.
  • FTS5 virtual tables — Electron creates them at runtime in fts-tasks.ts / fts-inbox.ts, not via Drizzle migrations. Deferred to M7 (search + sqlite-vec).

Every shared table has matching column and index sets, confirming spec §6.2 Risk #17 (Drizzle semantics missed) is not violated by the M2 port.

Carry-forward ledger

The command:parity audit catalogues 81 renderer commands deferred to later milestones. Breakdown:

  • M3 (vault FS + per-domain CRUD): 56
  • M4 (auth + crypto + linking): 10
  • M5 (CRDT engine): 5
  • M6 (sync ops orchestration): 5
  • M8.0 (lifecycle / native windows): 5

114 @memry/* workspace import references remain in renderer production code (informational, mostly @memry/contracts/* types — @memry/rpc/* and @memry/db-schema/* are M3+ work).

Risk coverage

  • Risk 17 (specta corner cases): 22 struct types export cleanly (stress test passed)
  • Risk 18 (Rust compile time): baseline ~110s release / ~3s incremental check
  • Risk 7 (debug-mode bench false negative): bench binary requires --release per docstring

🤖 Generated with Claude Code

h4yfans added 25 commits April 25, 2026 02:56
…otes/task_tags/inbox_items/saved_filters/settings)
…ers, signing_public_key, current_device unique index)
…ent_searches + table rebuilds, capture_source)
…s/note_metadata/property_definitions/folder_configs/calendar tables and alters)
Per-table domain structs derive specta::Type for Phase F binding
generation. Project includes clock/synced_at to match the final
schema after migrations 0015 + 0017 + 0018 (plan snippet elided
them; SQL is authoritative). projects_and_tasks_roundtrip exercises
INSERT + SELECT roundtrip via from_row for all three tables.
Per-table domain structs for the note vault tables (0006 + 0022).
Each derives specta::Type for Phase F binding generation.
PropertyDefinition uses r#type with explicit serde(rename) since
type is a Rust keyword; the DB column stays "type". One roundtrip
smoke test per table covers INSERT + SELECT via from_row.
… default

apply_pending now disables foreign_keys at connection scope before
replaying pending migrations and re-enables (with foreign_key_check)
after. SQLite silently no-ops the in-SQL PRAGMA foreign_keys=OFF inside
a transaction, so Drizzle's CREATE __new_X / INSERT / DROP X / RENAME
rebuild migrations (notably 0018) cascade-delete child rows on populated
DBs when the caller opened the connection with FK enforcement on. New
regression test rebuild_migrations_preserve_rows_when_fk_enforcement_is_on
applies 0000-0017, inserts FK-related rows, then runs apply_pending —
without the toggle the DROP TABLE projects step wipes statuses + tasks.

Also pass --features test-helpers in the cargo:test script so the
migrations_test target (which is required-features gated) actually runs
in the default verification path.
Phase D batch 1: 4 calendar domain structs covering migrations
0024 (foundation) + 0025 (event target_calendar_id) + 0026 (event
field_clocks) + 0027 (rich fields on events + external_events) +
0028 (source last_error). Field set is the post-migration union.

Each module exposes the canonical struct name expected by the Phase F
specta generator (CalendarEvent, CalendarSource, CalendarExternalEvent,
CalendarBinding) with a from_row helper. No CRUD yet — those land per
domain in later milestones.

Smoke tests: 4 roundtrip tests in migrations_test.rs. The CalendarEvent
test embeds Turkish UTF-8 (çğıöşü + uppercase variants) to guard against
encoding bugs at the SQLite/rusqlite boundary.
…structs

Phase D batch 2: 5 user-collection structs covering migrations
0000 (inbox_items base) + 0001 (bookmarks) + 0002 (inbox rebuild +
filing_history/inbox_item_tags/inbox_stats) + 0004 (reminders) +
0005 (inbox viewed_at, inbox_stats reminder count) + 0007
(tag_definitions) + 0011 (inbox sync metadata + local_only) + 0016
(tag_definitions clock) + 0018 (rebuild reset) + 0019 (inbox
capture_source) + 0023 (folder_configs).

Each module exposes the canonical struct name expected by the Phase F
specta generator (InboxItem, Bookmark, Reminder, TagDefinition,
FolderConfig) with a from_row helper. inbox_jobs is intentionally
deferred — that struct lands with the M8.7 snooze scheduler.

Notable column quirks:
- InboxItem.local_only is Option<bool>: the column was added by ALTER
  TABLE in 0011 with no NOT NULL, so backfilled rows were nullable.
  The 0018 rebuild kept the same nullable shape with default 0. Strict
  type follows the schema (Phase D gotcha #1).
- InboxItem.r#type uses the raw identifier escape; serde renames it
  back to `type` via #[serde(rename = ...)].
- TagDefinition uses `name` as primary key (no `id` column) and has
  no synced_at column even after the 0018 rebuild — only clock was
  added in 0016.

Smoke tests: 5 roundtrip tests in migrations_test.rs, all asserting
row.get path matches column names. Total tests: 11 (Phase B/C) + 4
(calendar) + 5 (collections) = 20 passing.
Two correctness bugs flagged by Codex review of the M2 migration runner:

1. Concurrent apply_pending could crash the second runner. Two app
   processes (dev hot-reload, double-launch) both bootstrapped, both
   read the same applied set, both computed the same pending list,
   both opened a BEGIN DEFERRED tx. Whichever lost the write-lock race
   crashed on either non-IF-NOT-EXISTS DDL (0001 `CREATE TABLE
   bookmarks`) or on the schema_migrations.name PRIMARY KEY. Fix:
   each per-migration tx now uses BEGIN IMMEDIATE so runners
   serialize on the SQLite write lock, and re-checks
   schema_migrations under that lock — if a sibling already recorded
   this migration, commit the empty tx and continue. The replay is
   now convergent.

2. apply_pending toggles PRAGMA foreign_keys = OFF for the rebuild
   migrations (0002/0009/0013/0018 use the CREATE __new_X / INSERT /
   DROP X / RENAME pattern that needs FK off at connection scope).
   If a migration failed mid-replay, the early ? return skipped the
   restore block, leaving FK enforcement permanently off on a
   connection that Db wraps in Arc<Mutex<Connection>> and reuses
   for every subsequent app write. Fix: scope the restore to run on
   both success and failure paths via an early-binding result, and
   make it best-effort so a restore failure doesn't mask the original
   migration error.

TDD: two regression tests added before the fix.

- apply_pending_restores_fk_state_on_migration_failure: pre-creates
  a colliding bookmarks table so 0001 fails, asserts FK is back to ON.
  RED: "PRAGMA foreign_keys must be restored to ON". GREEN with fix.

- apply_pending_is_safe_under_concurrent_runners: temp file DB, WAL
  set on a setup connection, two threads with a Barrier race
  apply_pending, asserts both succeed and schema_migrations has
  exactly one row per embedded migration. RED: "UNIQUE constraint
  failed: schema_migrations.name". GREEN with fix and stable across
  5 consecutive runs.
Adds the first real feature-domain IPC slice: a thin Setting KV store backed
by SQLite via three Tauri commands.

- db/settings.rs: get/set/list helpers with UPSERT semantics matching the
  0018 schema (key TEXT PRIMARY KEY, value TEXT, modified_at strftime'd).
- db/saved_filters.rs: SavedFilter struct + from_row mapper, present so the
  Phase F bindings stress-test exercises it without yet needing CRUD.
- commands/settings.rs: 3 #[tauri::command] + #[specta::specta] handlers,
  thin adapters over the db layer with input structs gated on
  rename_all = camelCase.
- lib.rs: invoke_handler! registers the 3 commands directly so the
  acceptance grep that scans lib.rs for settings::settings_* hits.
- tests/settings_test.rs (test-helpers gated): 5 cases covering missing
  key, roundtrip, upsert, sorted list, and Turkish-character UTF-8
  integrity through rusqlite TEXT.

Mock router stays in place for every other settings_* route; only
settings_get/set/list will flip to real Tauri once Task 15 lands.
Replaces the M1 stub generator with a tauri-specta Builder pipeline that
emits TypeScript bindings for the full M2 domain surface plus the three
settings commands. Adds specta-typescript as a direct dep so the generator
binary can call Typescript::default(); previously it was only available as
a transitive feature flag of tauri-specta and unreachable from our bin.

Output (354 lines):
- 1 commands const with settingsGet/settingsSet/settingsList wrappers that
  return tauri-specta's typed Result envelope
- AppError discriminated union (kind + message tag/content)
- 22 domain types (Bookmark, Calendar*, FolderConfig, InboxItem,
  NoteMetadata, NotePosition, Project, PropertyDefinition, Reminder,
  SavedFilter, SearchReason, Setting, Status, Sync*, TagDefinition, Task)
- 2 input types for settings commands
- typedError runtime helper

camelCase rename hygiene verified: no created_at/modified_at/project_id
fields leak through. The bindings:generate + bindings:check pipeline runs
cargo against the generator bin to keep the file deterministic; the
checked-in copy now matches a fresh regeneration.

Renderer typecheck stays clean — nothing imports from @/generated/bindings
yet. Phase F Task 15 hooks the Setting type up via the useSettings slice.
…wired

Phase F's renderer slice. The IPC wrapper now lights up the three settings
commands while every other domain stays on the JS-side mock router.

- src/lib/ipc/invoke.ts: realCommands set gains settings_get/set/list. The
  router gate stays untouched, so unrelated mocks (settings_get_section,
  settings_set_general_settings, calendar/journal/tab/AI groups, etc.)
  continue to serve their M1 mock shapes until each domain's Rust handler
  lands in a later milestone.
- src/hooks/useSettings.ts: new file. Tanstack Query bindings for the three
  commands — useSetting(key) / useSettings() / useSetSetting() — typed
  against `Setting` from generated/bindings.ts. Mutation onSuccess
  invalidates the per-key + list query keys.
- tests/useSettings.test.tsx: end-to-end roundtrip through the real invoke
  wrapper using a stubbed @tauri-apps/api/core. Follows invoke.test.ts's
  pattern of vi.unmock'ing @/lib/ipc/invoke first so we bypass the global
  setup's auto-mock and exercise the live shouldUseMock branching. Three
  cases: roundtrip, list reflection after mutation, null for unset key.

Settings package residue removed from acceptance grep paths
(src/lib/ipc/**, src/hooks/useSettings.ts, src/pages/settings/**) by
rehoming the two type-only contracts touched by this slice into
src/types/settings-schemas.ts: ShortcutBinding (used by
pages/settings/shortcuts-section.tsx) and CalendarSettings (used by
pages/settings/calendar-section.tsx). Deeper consumers in src/lib/ and
src/hooks/ still import @memry/contracts/settings-schemas and travel
forward via the Phase G carry-forward ledger.

Manual dev smoke (toggle setting → sqlite3 confirms row) is PENDING — no
UI consumer wires useSetting/useSetSetting yet. The hook + bindings +
Rust path are already proven by 3 vitest cases + 5 cargo cases, but
end-to-end UI verification waits on either Phase G's broader rollout or
a one-off temporary toggle the user can drive.
…V path

Codex adversarial review on Phase F flagged that the live onboarding flow
still routed through the JS-side mock router because only the new generic
useSettings hook was added — none of the existing domain-specific section
hooks were migrated. Onboarding completion appeared to persist in dev
because the mock module's state object survives between hook remounts, but
nothing actually reached SQLite.

This commit migrates the production caller (useGeneralSettings) onto the
KV path while keeping the hook's external contract intact.

- Read: invoke('settings_get', { input: { key: 'general' } }) returns the
  serialized JSON blob (or null on first run). parseStoredSettings merges
  any stored partial against DEFAULTS so forward-compat additions survive
  schema drift.
- Write: settingsRef tracks the latest committed value; updateSettings
  derives `next = { ...current, ...patch }`, JSON.stringifies it, and
  pushes through invoke('settings_set', ...) before mutating local state.
  The optimistic update is skipped until the IPC succeeds.
- Event subscription stays — settings-changed broadcasts still merge into
  local state for cross-window propagation when the eventing layer comes
  online in M3+.

Regression coverage in use-general-settings.test.tsx (TDD red→green):
1. Asserts the hook only invokes settings_get / settings_set against the
   real Tauri core — never the legacy settings_get_general_settings or
   settings_set_general_settings mock routes.
2. onboardingCompleted survives unmount + remount through the real KV
   store (this was the exact case Codex flagged).
3. Multiple partial updates compose without losing prior fields (proves
   the JSON merge layer doesn't drop properties on second write).

Test setup follows invoke.test.ts: vi.unmock('@/lib/ipc/invoke') bypasses
the global setup-dom auto-mock so the assertion is on the real wrapper,
and vi.mock('@tauri-apps/api/core') exposes a __resetStore knob that
beforeEach uses to isolate the in-memory KV between cases.
- scripts/dev-reset.sh, new-migration.ts, schema-diff.ts (DB tooling)
- scripts/command-parity-audit.ts (renderer↔mock↔Rust classification ledger)
- Replace electron-log/renderer logger with a Tauri-safe console wrapper
- Harden port:audit to flag electron-log + bare 'electron' imports and
  track @memry/* residue (114 in production code, informational ledger)
- Align updater mocks with use-app-updater hook (get_state /
  check_for_updates / download_update / quit_and_install) using the
  contract's AppUpdateState shape; legacy updater_check / _download /
  _install names removed
- Add notify_flush_done as a real no-op Rust command so the renderer's
  flush-on-quit hook stops 404-ing through the mock router; full
  lifecycle coordinator deferred to M8.0
- Register db:reset, db:new-migration, db:schema-diff, command:parity
  scripts in package.json
- Update setup-dom.ts mock target from electron-log/renderer to
  @/lib/logger so logger spies still work in tests
…cking

This commit introduces the first real feature-domain IPC slice for managing settings through a thin KV store backed by SQLite. It includes:

- Implementation of settings_get, settings_set, and settings_list commands in Rust.
- Database helpers for get/set/list operations with UPSERT semantics.
- Integration of these commands into the Tauri command handler.
- Tests covering various scenarios including missing keys and UTF-8 integrity.

The mock router remains for other settings routes, with a transition to real Tauri commands planned for future milestones.
@h4yfans
h4yfans merged commit 04a0b43 into main Apr 25, 2026
7 of 9 checks passed
@h4yfans
h4yfans deleted the m2/db-schemas-migrations branch April 25, 2026 23:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant