| name | migrate-to-sqlite |
| description | Migrate a TinyBase table to SQLite. Use when asked to move a data domain (e.g. templates, vocabs) from the TinyBase store to the app SQLite database. |
Status
Keep up to date as each PR lands. Outer box = fully done across both
phases. Sub-bullets track sub-states where relevant.
This migration targets the TinyBase main store only. The separate
TinyBase settings store is out of scope unless the plan is explicitly
expanded to include it.
Strategy
Two-phase, per-domain migration. Each phase is many small PRs.
Phase 0 — Decouple consumers
Before any storage swap, move every TinyBase call behind a domain hook
living in apps/desktop/src/<domain>/hooks.ts (or <domain>/hooks/*).
Hook return shapes are plain TypeScript; no TinyBase types leak out.
Consumer code stops importing ~/store/tinybase/store/main.
Scope note: this applies to consumers of
~/store/tinybase/store/main and the legacy ~/store/tinybase/hooks
barrel. Do not start wrapping settings.UI.* usage behind new hooks as
part of this effort; ~/store/tinybase/store/settings is intentionally
out of scope for this migration.
Why: one storage-swap PR per domain touches 1 file (the hook module),
not 20–50 consumer files.
Enforced by hypr/no-raw-tinybase in eslint-plugin-hypr.mjs.
.oxlintrc.json keeps a TINYBASE_MIGRATION_PENDING override that
shrinks as each domain is cleaned. CI gates this via
.github/workflows/lint.yaml.
Phase 1 — Swap storage per domain
- Rust migration + ops + Drizzle schema (steps below).
- Flip writes in the hook module:
db.insert()/update()/delete().
- Shadow-hydrate TinyBase from SQLite so read hooks that haven't
moved yet keep working. A small adapter subscribes to
db-live-query
and mirrors rows into the in-memory TinyBase store. One-way only:
SQLite is the source of truth.
- Swap read hooks to
useDrizzleLiveQuery one at a time. Consumers
untouched because hook signatures are stable.
- Once no TinyBase consumers remain for the domain, remove the shadow
adapter, the TinyBase schema entries, and the persister.
Skip the shadow bridge only for leaf-clean domains (no cross-table
indexes/queries into or out of the domain, and <10 consumer sites).
Architecture
- Schema source of truth: Rust migration in
crates/db-app/migrations/
- Drizzle mirror:
packages/db/src/schema.ts (typed TS query interface, not schema management)
- Reads (reactive):
useDrizzleLiveQuery — calls .toSQL() on a Drizzle query, feeds {sql, params} to the underlying useLiveQuery which uses subscribe() from @hypr/plugin-db
- Reads (imperative):
db.select()... through the Drizzle sqlite-proxy driver
- Writes:
db.insert(), db.update(), db.delete() through the Drizzle sqlite-proxy driver, wrapped in useMutation from tanstack-query
- Reactivity loop: write via
execute → SQLite change → Rust db-live-query notifies subscribers → useLiveQuery fires onData → React re-renders. No manual invalidation needed.
Package layers
The DB stack uses a factory/DI pattern across four packages:
@hypr/db-runtime (packages/db-runtime/) — type contracts only: LiveQueryClient, DrizzleProxyClient, shared row/query types.
@hypr/db (packages/db/) — Drizzle schema (schema.ts) + createDb(client) factory using drizzle-orm/sqlite-proxy. Re-exports Drizzle operators (eq, and, sql, etc.).
@hypr/db-tauri (packages/db-tauri/) — Tauri-specific client that binds execute/executeProxy/subscribe from @hypr/plugin-db to the db-runtime types.
@hypr/db-react (packages/db-react/) — createUseLiveQuery(client) and createUseDrizzleLiveQuery(client) factories.
These are wired together in apps/desktop/src/db/index.ts, which exports db, useLiveQuery, and useDrizzleLiveQuery. Consumer code imports from ~/db, not directly from the packages.
Per-domain steps (Phase 1)
Assumes Phase 0 already landed for this domain — consumers go through
<domain>/hooks.ts, not raw UI.*.
1. Rust migration
Add a new timestamped .sql file in crates/db-app/migrations/. Convention: YYYYMMDDHHMMSS_name.sql.
Do NOT include user_id columns — it was a TinyBase-era pattern with a hardcoded default. It will be redesigned when multi-device/team support lands.
2. Rust ops (optional but recommended)
Add <domain>_types.rs and <domain>_ops.rs in crates/db-app/src/ with typed sqlx::FromRow structs and CRUD functions. Export from lib.rs. These are used by other Rust code and legacy import; the TS side uses Drizzle instead.
3. Legacy data import
If the domain had a TinyBase JSON persister file (e.g. templates.json), add an import function in plugins/db/src/migrate.rs that reads the old file and upserts rows. Call it from plugins/db/src/runtime.rs during startup. Guard with an "already imported" check (e.g. table non-empty).
4. Drizzle schema
Add the table definition to packages/db/src/schema.ts mirroring the migration. Use { mode: "json" } for JSON text columns, { mode: "boolean" } for integer boolean columns. Re-export from packages/db/src/index.ts if adding new operator re-exports.
5. Swap hook internals
Replace the hook module's TinyBase calls with Drizzle. Hook signatures
stay the same, so consumer code doesn't change.
useDrizzleLiveQuery(db.select()...) for reactive reads
db.select()... for imperative reads (returns parsed objects via proxy driver)
db.insert(), db.update(), db.delete() for writes, wrapped in useMutation
Import db and useDrizzleLiveQuery from ~/db, and schema tables/operators from @hypr/db.
Live query results come from Rust subscribe as raw objects (not through the Drizzle driver), so mapRows must handle two things:
- JSON parsing for JSON text columns (e.g.
sections_json, targets_json).
- snake_case → camelCase mapping. Live rows use the raw SQLite column names (
pin_order, targets_json), while Drizzle's $inferSelect uses camelCase (pinOrder, targetsJson). Define a separate <Domain>LiveRow type with snake_case keys for mapRows, distinct from the Drizzle inferred type. See TemplateLiveRow in apps/desktop/src/templates/queries.ts for the pattern.
6. Shadow bridge (skip for leaf-clean domains)
Add a small module that hydrates the TinyBase <domain> table from
SQLite on startup and subscribes to db-live-query to mirror subsequent
changes. One-way: SQLite → TinyBase. Retire once all consumers have
swapped.
7. Remove TinyBase artifacts
- Table definition from
packages/store/src/tinybase.ts
- Query definitions from
store/tinybase/store/main.ts (both QUERIES object and _QueryResultRows type)
- Persister files (e.g.
store/tinybase/persister/<domain>/)
- Persister registration from
store/tinybase/store/persisters.ts
- Hooks from
store/tinybase/hooks/ if they existed
- Shadow-bridge module if one was added
- Associated tests and test wrapper setup
8. Verify
cargo check and cargo test -p db-app -p tauri-plugin-db
pnpm -F @hypr/desktop typecheck
pnpm -F @hypr/desktop test
npx oxlint --quiet apps/desktop/src/ (the hypr/no-raw-tinybase CI gate)
pnpm exec dprint fmt