Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions .agents/skills/migrate-to-sqlite/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,22 @@ description: Migrate a TinyBase table to SQLite. Use when asked to move a data d

- **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 `useLiveQuery` which uses `subscribe()` from `@hypr/plugin-db`
- **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:

1. `@hypr/db-runtime` (`packages/db-runtime/`) — type contracts only: `LiveQueryClient`, `DrizzleProxyClient`, shared row/query types.
2. `@hypr/db` (`packages/db/`) — Drizzle schema (`schema.ts`) + `createDb(client)` factory using `drizzle-orm/sqlite-proxy`. Re-exports Drizzle operators (`eq`, `and`, `sql`, etc.).
3. `@hypr/db-tauri` (`packages/db-tauri/`) — Tauri-specific client that binds `execute`/`executeProxy`/`subscribe` from `@hypr/plugin-db` to the `db-runtime` types.
4. `@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.**

## Steps

### 1. Rust migration
Expand Down Expand Up @@ -39,7 +50,11 @@ Replace raw TinyBase reads/writes with:
- `db.select()...` for imperative reads (returns parsed objects via proxy driver)
- `db.insert()`, `db.update()`, `db.delete()` for writes, wrapped in `useMutation`

Live query results come from Rust `subscribe` as raw objects (not through Drizzle driver), so `mapRows` must still handle JSON parsing for JSON columns.
Import `db` and `useDrizzleLiveQuery` from `~/db` (the app-level wiring module), 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. Remove TinyBase artifacts

Expand Down
20 changes: 10 additions & 10 deletions apps/desktop/src/calendar/components/apple/calendar-selection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import {
type CalendarItem,
CalendarSelection,
} from "~/calendar/components/calendar-selection";
import { useCalendars, useToggleCalendarEnabled } from "~/calendar/queries";
import { useMountEffect } from "~/shared/hooks/useMountEffect";
import * as main from "~/store/tinybase/store/main";

const SUBSCRIBED_SOURCE_NAME = "Subscribed Calendars";

Expand Down Expand Up @@ -43,20 +43,20 @@ export function useAppleCalendarSelection() {
const { cancelDebouncedSync, status, scheduleDebouncedSync, scheduleSync } =
useSync();

const store = main.UI.useStore(main.STORE_ID);
const calendars = main.UI.useTable("calendars", main.STORE_ID);
const allCalendars = useCalendars();
const toggleEnabled = useToggleCalendarEnabled();

const groups = useMemo((): CalendarGroup[] => {
const appleCalendars = Object.entries(calendars).filter(
([_, cal]) => cal.provider === "apple",
const appleCalendars = allCalendars.filter(
(cal) => cal.provider === "apple",
);

const grouped = new Map<string, CalendarItem[]>();
for (const [id, cal] of appleCalendars) {
for (const cal of appleCalendars) {
const source = cal.source || "Apple Calendar";
if (!grouped.has(source)) grouped.set(source, []);
grouped.get(source)!.push({
id,
id: cal.id,
title: cal.name || "Untitled",
color: cal.color ?? "#888",
enabled: cal.enabled ?? false,
Expand All @@ -73,14 +73,14 @@ export function useAppleCalendarSelection() {
if (b.sourceName === SUBSCRIBED_SOURCE_NAME) return -1;
return 0;
});
}, [calendars]);
}, [allCalendars]);

const handleToggle = useCallback(
(calendar: CalendarItem, enabled: boolean) => {
store?.setPartialRow("calendars", calendar.id, { enabled });
void toggleEnabled(calendar.id, enabled);
scheduleDebouncedSync();
},
[store, scheduleDebouncedSync],
[toggleEnabled, scheduleDebouncedSync],
);

const handleRefresh = useCallback(() => {
Expand Down
42 changes: 15 additions & 27 deletions apps/desktop/src/calendar/components/event-chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,38 +11,32 @@ import {
import { cn } from "@hypr/utils";

import { toTz, useTimezone } from "~/calendar/hooks";
import { useCalendarById, useEventById } from "~/calendar/queries";
import { EventDisplay } from "~/session/components/outer-header/metadata";
import { useEvent } from "~/store/tinybase/hooks";
import * as main from "~/store/tinybase/store/main";
import { getOrCreateSessionForEventId } from "~/store/tinybase/store/sessions";
import { useTabs } from "~/store/zustand/tabs";

function useCalendarColor(calendarId: string | null): string | null {
const calendar = main.UI.useRow("calendars", calendarId ?? "", main.STORE_ID);
const calendar = useCalendarById(calendarId);
if (!calendarId) return null;
return calendar?.color ? String(calendar.color) : null;
return calendar?.color ?? null;
}

export function EventChip({ eventId }: { eventId: string }) {
const tz = useTimezone();
const event = main.UI.useResultRow(
main.QUERIES.timelineEvents,
eventId,
main.STORE_ID,
);
const calendarColor = useCalendarColor(
(event?.calendar_id as string) ?? null,
);
const event = useEventById(eventId);
const calendarColor = useCalendarColor(event?.calendarId ?? null);

if (!event || !event.title) {
return null;
}

const isAllDay = !!event.is_all_day;
const isAllDay = !!event.isAllDay;
const color = calendarColor ?? "#888";

const startedAt = event.started_at
? format(toTz(event.started_at as string, tz), "h:mm a")
const startedAt = event.startedAt
? format(toTz(event.startedAt, tz), "h:mm a")
: null;

return (
Expand All @@ -56,7 +50,7 @@ export function EventChip({ eventId }: { eventId: string }) {
])}
style={{ backgroundColor: color }}
>
{event.title as string}
{event.title}
</button>
) : (
<button
Expand All @@ -69,7 +63,7 @@ export function EventChip({ eventId }: { eventId: string }) {
className="w-[2.5px] shrink-0 self-stretch rounded-full"
style={{ backgroundColor: color }}
/>
<span className="truncate">{event.title as string}</span>
<span className="truncate">{event.title}</span>
{startedAt && (
<span className="ml-auto shrink-0 font-mono text-neutral-400">
{startedAt}
Expand All @@ -93,22 +87,16 @@ export function EventChip({ eventId }: { eventId: string }) {
}

function EventPopoverContent({ eventId }: { eventId: string }) {
const event = useEvent(eventId);
const event = useEventById(eventId);
const store = main.UI.useStore(main.STORE_ID);
const openNew = useTabs((state) => state.openNew);

const eventRow = main.UI.useResultRow(
main.QUERIES.timelineEvents,
eventId,
main.STORE_ID,
);

const handleOpen = useCallback(() => {
const handleOpen = useCallback(async () => {
if (!store) return;
const title = (eventRow?.title as string) || "Untitled";
const sessionId = getOrCreateSessionForEventId(store, eventId, title);
const title = event?.title || "Untitled";
const sessionId = await getOrCreateSessionForEventId(store, eventId, title);
openNew({ type: "sessions", id: sessionId });
}, [store, eventId, eventRow?.title, openNew]);
}, [store, eventId, event?.title, openNew]);

if (!event) {
return null;
Expand Down
36 changes: 17 additions & 19 deletions apps/desktop/src/calendar/components/oauth/calendar-selection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
CalendarSelection,
} from "~/calendar/components/calendar-selection";
import type { CalendarProvider } from "~/calendar/components/shared";
import * as main from "~/store/tinybase/store/main";
import { useCalendars, useToggleCalendarEnabled } from "~/calendar/queries";

export function OAuthCalendarSelection({
groups,
Expand All @@ -34,33 +34,32 @@ export function OAuthCalendarSelection({

export function useOAuthCalendarSelection(config: CalendarProvider) {
const queryClient = useQueryClient();
const store = main.UI.useStore(main.STORE_ID);
const calendars = main.UI.useTable("calendars", main.STORE_ID);
const allCalendars = useCalendars();
const toggleEnabled = useToggleCalendarEnabled();
const { cancelDebouncedSync, status, scheduleDebouncedSync, scheduleSync } =
useSync();

const { groups, connectionSourceMap } = useMemo(() => {
const providerCalendars = Object.entries(calendars).filter(
([_, cal]) => cal.provider === config.id,
const providerCalendars = allCalendars.filter(
(cal) => cal.provider === config.id,
);

const sourceMap = new Map<string, string>();

for (const [_, cal] of providerCalendars) {
// HACK: derive connection_id -> source mapping from calendar entries
if (cal.source && cal.connection_id) {
sourceMap.set(cal.connection_id as string, cal.source as string);
for (const cal of providerCalendars) {
if (cal.source && cal.connectionId) {
sourceMap.set(cal.connectionId, cal.source);
}
}

const nonNullSources = new Set(
providerCalendars
.map(([_, cal]) => {
.map((cal) => {
if (cal.source) {
return cal.source;
}
if (cal.connection_id) {
return sourceMap.get(cal.connection_id as string);
if (cal.connectionId) {
return sourceMap.get(cal.connectionId);
}
return undefined;
})
Expand All @@ -74,9 +73,8 @@ export function useOAuthCalendarSelection(config: CalendarProvider) {
{ connectionId?: string; calendars: CalendarItem[] }
>();

for (const [id, cal] of providerCalendars) {
const connectionId =
typeof cal.connection_id === "string" ? cal.connection_id : undefined;
for (const cal of providerCalendars) {
const connectionId = cal.connectionId || undefined;
const source =
cal.source ||
(connectionId ? sourceMap.get(connectionId) : undefined) ||
Expand All @@ -90,7 +88,7 @@ export function useOAuthCalendarSelection(config: CalendarProvider) {
group.connectionId = connectionId;
}
group.calendars.push({
id,
id: cal.id,
title: cal.name ?? "Untitled",
color: cal.color ?? "#4285f4",
enabled: cal.enabled ?? false,
Expand All @@ -105,14 +103,14 @@ export function useOAuthCalendarSelection(config: CalendarProvider) {
})),
connectionSourceMap: sourceMap,
};
}, [calendars, config.id]);
}, [allCalendars, config.id]);

const handleToggle = useCallback(
(calendar: CalendarItem, enabled: boolean) => {
store?.setPartialRow("calendars", calendar.id, { enabled });
void toggleEnabled(calendar.id, enabled);
scheduleDebouncedSync();
},
[store, scheduleDebouncedSync],
[toggleEnabled, scheduleDebouncedSync],
);

const handleRefresh = useCallback(() => {
Expand Down
6 changes: 2 additions & 4 deletions apps/desktop/src/calendar/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { safeParseDate } from "@hypr/utils";
import { TZDate } from "@hypr/utils";

import { useTimelineEvents } from "~/calendar/queries";
import { useConfigValue } from "~/shared/config";
import { useIgnoredEvents } from "~/store/tinybase/hooks";
import * as main from "~/store/tinybase/store/main";
Expand Down Expand Up @@ -70,10 +71,7 @@ function compareNullableDates(a: string | undefined, b: string | undefined) {
export function useCalendarData(): CalendarData {
const tz = useTimezone();

const eventsTable = main.UI.useResultTable(
main.QUERIES.timelineEvents,
main.STORE_ID,
);
const eventsTable = useTimelineEvents();
const sessionsTable = main.UI.useResultTable(
main.QUERIES.timelineSessions,
main.STORE_ID,
Expand Down
Loading
Loading