Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a new renderer “data layer” abstraction that can switch between Zustand-backed state and a DB-backed (SQLite/Drizzle) implementation accessed via tRPC, as part of the ongoing migration away from persisted Zustand stores.
Changes:
- Add
@/dataservice interfaces + React Query hooks (with aDataBridgefor Zustand-mode cache invalidation) and swap multiple components to use them instead of direct Zustand store access. - Add DB-backed implementations on both renderer (vanilla tRPC client services) and main process (new
appRouter.data.*procedures + Drizzle schema/migrations). - Initialize and migrate the user DB in Electron main, and add Drizzle tooling (
drizzle-kit,drizzle-orm) + scripts.
Reviewed changes
Copilot reviewed 45 out of 47 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/vite-env.d.ts | Adds VITE_DATASOURCE build flag typing. |
| src/main.tsx | Mounts DataBridge at app root. |
| src/lib/trpc.tsx | Adds a vanilla (non-React) tRPC client creator for services. |
| src/hooks/use-mod-installer.ts | Switches mod state updates to @/data actions. |
| src/hooks/use-mod-actions.ts | Switches mod state updates to @/data actions. |
| src/data/zustand.ts | Adds Zustand-backed async service implementations. |
| src/data/trpc-services.ts | Adds tRPC-backed service implementations (DB mode). |
| src/data/services.ts | Exposes service singletons that switch by datasource. |
| src/data/interfaces.ts | Introduces data service interfaces + domain types. |
| src/data/index.ts | Provides @/data entrypoint exports for services/hooks/bridge. |
| src/data/hooks.ts | Adds React Query hooks + DataBridge for Zustand-mode invalidation. |
| src/data/datasource.ts | Adds datasource resolution logic via VITE_DATASOURCE. |
| src/components/layout/global-rail.tsx | Migrates game/profile reads + actions to @/data hooks. |
| src/components/features/settings/settings-dialog.tsx | Migrates managed game list source to @/data. |
| src/components/features/settings/panels/other-panel.tsx | Migrates settings reads/writes to @/data. |
| src/components/features/settings/panels/locations-panel.tsx | Migrates settings reads/writes to @/data. |
| src/components/features/settings/panels/game-settings-panel.tsx | Migrates profile/settings/mod/game actions to @/data. |
| src/components/features/settings/panels/downloads-panel.tsx | Migrates download settings reads/writes to @/data. |
| src/components/features/mods-library.tsx | Migrates profile/mod/settings usage to @/data. |
| src/components/features/mod-tile.tsx | Migrates mod/profile state to @/data. |
| src/components/features/mod-list-item.tsx | Migrates mod/profile state to @/data. |
| src/components/features/mod-inspector.tsx | Migrates mod/profile/settings state to @/data. |
| src/components/features/game-dashboard.tsx | Migrates profile/mod/settings state/actions to @/data. |
| src/components/features/downloads-page.tsx | Migrates mod/profile state reads to @/data. |
| src/components/features/dependencies/dependency-download-dialog.tsx | Migrates mod/profile/settings reads + actions to @/data. |
| src/components/features/config-editor/config-editor-center.tsx | Migrates active profile selection to @/data. |
| src/components/features/add-game-dialog.tsx | Migrates game/profile/settings actions to @/data. |
| src/components/download-bridge.tsx | Starts sourcing settings via @/data for syncing to main. |
| src/components/app-bootstrap.tsx | Migrates initialization logic to @/data hooks/actions. |
| package.json | Adds Drizzle deps and DB scripts. |
| pnpm-lock.yaml | Locks new Drizzle dependencies. |
| electron/trpc/trpc.ts | Centralizes tRPC init (superjson transformer). |
| electron/trpc/router.ts | Wires in new data router under appRouter. |
| electron/trpc/data/index.ts | Adds dataRouter composition (games/settings/profiles/mods). |
| electron/trpc/data/games.ts | Implements DB-backed game procedures. |
| electron/trpc/data/settings.ts | Implements DB-backed settings procedures. |
| electron/trpc/data/profiles.ts | Implements DB-backed profile procedures. |
| electron/trpc/data/mods.ts | Implements DB-backed mod state procedures. |
| electron/main.ts | Initializes DB + runs migrations at app startup; closes DB on quit. |
| electron/db/schema.ts | Defines Drizzle SQLite schema for user data. |
| electron/db/index.ts | Adds SQLite/Drizzle DB initialization and singleton accessors. |
| electron/db/migrate.ts | Runs Drizzle migrations at startup. |
| electron/db/migrations/0000_perpetual_mentallo.sql | Initial SQL migration. |
| electron/db/migrations/meta/_journal.json | Drizzle migration journal metadata. |
| electron/db/migrations/meta/0000_snapshot.json | Drizzle schema snapshot metadata. |
| drizzle.config.ts | Drizzle-kit configuration. |
| docs/architecture-database-migration.md | Adds architecture doc for Zustand → DB migration plan. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // In development, migrations are relative to project root. | ||
| // In production, they need to be bundled — adjust path as needed. | ||
| const migrationsFolder = join(__dirname, "../../electron/db/migrations") | ||
| migrate(db, { migrationsFolder }) |
There was a problem hiding this comment.
runMigrations() uses a hard-coded migrations folder relative to __dirname ("../../electron/db/migrations"). In packaged builds __dirname points into the bundled output, so this path is likely invalid unless migrations are explicitly copied there. Consider resolving migrationsFolder via app.getAppPath()/process.resourcesPath and ensure electron-builder includes the migrations directory as an extraResource.
| export const globalSettings = sqliteTable("global_settings", { | ||
| id: integer("id").primaryKey().$default(() => 1), | ||
|
|
There was a problem hiding this comment.
global_settings is intended to be a singleton (id=1) but the schema doesn't enforce that only id=1 can exist. Consider adding a CHECK constraint (id = 1) or another enforcement mechanism to prevent accidental insertion of multiple rows.
| export const DATASOURCE: "db" | "zustand" = | ||
| (import.meta.env.VITE_DATASOURCE as "db" | "zustand" | undefined) ?? "db" |
There was a problem hiding this comment.
DATASOURCE defaults to "db" when VITE_DATASOURCE is unset. In non-Electron/web mode this will cause the data hooks/services to attempt to create a vanilla tRPC client and throw (electronTRPC is not available). Consider defaulting to "zustand" (preserve current behavior) or deriving the default from hasElectronTRPC()/import.meta.env.MODE and requiring an explicit flag to enable DB mode.
|
|
||
| // Select the game | ||
| selectGame(defaultGameId) | ||
| }, []) |
There was a problem hiding this comment.
This initialization effect has an empty dependency array but depends on defaultGameId and several functions (gameMut, profileMut, getPerGame, selectGame). If defaultGameId is initially null (e.g., during persisted store rehydration) and becomes available later, this effect will never run and the app won't auto-select/manage the default game. Include the relevant dependencies and keep the hasInitialized guard to ensure it still runs only once.
| }, []) | |
| }, [defaultGameId, gameMut, profileMut, getPerGame, selectGame]) |
| // Check if auto-install is enabled (read from store directly to get latest value) | ||
| const autoInstallEnabled = useSettingsStore.getState().global.autoInstallMods | ||
| if (autoInstallEnabled && event.result?.extractedPath) { | ||
| // Get active profile for this game | ||
| const activeProfileId = useProfileStore.getState().activeProfileIdByGame[task.gameId] | ||
|
|
There was a problem hiding this comment.
In DB mode, this callback reads autoInstallMods and activeProfileId from Zustand stores (useSettingsStore/useProfileStore), but those stores are no longer the source of truth when VITE_DATASOURCE=db. This can make auto-install behavior incorrect. Consider sourcing these values from the data services (or store the latest values from useSettingsData/useProfileData into refs that the IPC callback can read).
| @@ -162,10 +154,10 @@ export function DownloadBridge() { | |||
| version: task.modVersion, | |||
| extractedPath: event.result.extractedPath, | |||
| }) | |||
|
|
|||
| // Mark as installed in state | |||
| useModManagementStore.getState().installMod(activeProfileId, task.modId, task.modVersion) | |||
|
|
|||
There was a problem hiding this comment.
After installing a mod via profiles.installMod, this code updates only the Zustand mod-management store. In DB mode that will not persist to SQLite, so the installed mod won't appear in the DB-backed UI. Record the install via the DB/tRPC data router (e.g., data.mods.install or modService.install) and invalidate the relevant React Query keys instead of (or in addition to) updating the Zustand store.
| // Activate target | ||
| await tx | ||
| .update(profile) | ||
| .set({ isActive: true }) | ||
| .where(eq(profile.id, input.profileId)) |
There was a problem hiding this comment.
setActive activates the target profile by id only. If a profileId from a different game is provided, this can incorrectly move the active flag across games and leave the requested game with no active profile. Add gameId to the WHERE clause (and/or verify the profile belongs to the game before updating).
| // Activate target | |
| await tx | |
| .update(profile) | |
| .set({ isActive: true }) | |
| .where(eq(profile.id, input.profileId)) | |
| // Activate target (ensure it belongs to the same game) | |
| await tx | |
| .update(profile) | |
| .set({ isActive: true }) | |
| .where(and(eq(profile.id, input.profileId), eq(profile.gameId, input.gameId))) |
No description provided.