Migrate a TinyBase table to SQLite. Use when asked to move a data domain (e.g. templates, chat shortcuts, vocabs) from the TinyBase store to the app SQLite database.
Keep up to date as each PR lands. Outer box = fully done across both phases. Sub-bullets track sub-states where relevant.
templates — already Drizzle, no Phase 0 neededcalendars
useCalendar, useEnabledCalendars)services/calendar/ctx.ts has a cross-domain
calendars+events transaction; lands with events PRevents
sessionstranscriptshumansorganizationsenhanced_notes
session/hooks/useEnhancedNotes.tsmapping_session_participantmapping_tag_sessionmapping_mentiontagschat_groupschat_messages
chat/store/*chat_shortcutstasksmemories
settings/memory/custom-vocabulary.tsxdaily_notesTwo-phase, per-domain migration. Each phase is many small PRs.
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.
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.
db.insert()/update()/delete().db-live-query
and mirrors rows into the in-memory TinyBase store. One-way only:
SQLite is the source of truth.useDrizzleLiveQuery one at a time. Consumers
untouched because hook signatures are stable.Skip the shadow bridge only for leaf-clean domains (no cross-table indexes/queries into or out of the domain, and <10 consumer sites).
crates/db-app/migrations/packages/db/src/schema.ts (typed TS query interface, not schema management)useDrizzleLiveQuery — calls .toSQL() on a Drizzle query, feeds {sql, params} to the underlying useLiveQuery which uses subscribe() from @hypr/plugin-dbdb.select()... through the Drizzle sqlite-proxy driverdb.insert(), db.update(), db.delete() through the Drizzle sqlite-proxy driver, wrapped in useMutation from tanstack-queryexecute → SQLite change → Rust db-live-query notifies subscribers → useLiveQuery fires onData → React re-renders. No manual invalidation needed.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.
Assumes Phase 0 already landed for this domain — consumers go through
<domain>/hooks.ts, not raw UI.*.
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.
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.
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).
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.
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 readsdb.select()... for imperative reads (returns parsed objects via proxy driver)db.insert(), db.update(), db.delete() for writes, wrapped in useMutationImport 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:
sections_json, targets_json).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.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.
packages/store/src/tinybase.tsstore/tinybase/store/main.ts (both QUERIES object and _QueryResultRows type)store/tinybase/persister/<domain>/)store/tinybase/store/persisters.tsstore/tinybase/hooks/ if they existedcargo check and cargo test -p db-app -p tauri-plugin-dbpnpm -F @hypr/desktop typecheckpnpm -F @hypr/desktop testnpx oxlint --quiet apps/desktop/src/ (the hypr/no-raw-tinybase CI gate)pnpm exec dprint fmt