Skip to content

feat(desktop): persist Electric collections to SQLite for offline launch#3841

Merged
saddlepaddle merged 3 commits intomainfrom
tanstack-db-persistence
Apr 28, 2026
Merged

feat(desktop): persist Electric collections to SQLite for offline launch#3841
saddlepaddle merged 3 commits intomainfrom
tanstack-db-persistence

Conversation

@saddlepaddle
Copy link
Copy Markdown
Collaborator

@saddlepaddle saddlepaddle commented Apr 28, 2026

Summary

  • Wraps all 21 Electric synced collections in apps/desktop with @tanstack/electron-db-sqlite-persistence so the local cache survives cold start. SQLite db lives at ~/.superset/tanstack-db.sqlite, mirroring the existing ~/.superset/local.db convention. The 6 local-only collections stay on localStorageCollectionOptions (already offline-capable; migrating them carries data-migration risk that's out of scope here).
  • Drops useLiveQuery's isLoading guards on the Tasks view. isLoading is collection.status === "loading", which alpha 0.1.9 only flips to ready after Electric's first up-to-date handshake — so the page sat on a spinner forever offline even though cached rows were available. Now gated on data presence.

How it works

Renderer constructs persistence via createElectronSQLitePersistence({ invoke }) using the existing window.ipcRenderer.invoke preload bridge — no preload changes. Main process opens better-sqlite3 (already a dep, already rebuilt for Electron via electron-builder) and registers IPC handlers in whenReady after initAppState, disposes them in before-quit. New helper createPersistedElectricCollection wraps electricCollectionOptions with persistedCollectionOptions({ persistence, schemaVersion: 1 }) + index defaults; mechanically applied to all 21 sites.

Per-org isolation: collection ids are already org-scoped (tasks-${organizationId}), so SQLite tables don't collide across orgs. Per-user: matches existing local.db convention (machine-wide, not cleared on sign-out).

Test plan

  • Cold-start online (second launch): sidebar/workspace layout populate from SQLite immediately, before any network request resolves
  • Cold-start offline (after one online launch to prime cache): launch with Electric off — sidebar renders projects, workspace clicks reach local host service, Tasks view renders cached rows or empty state instead of spinning
  • Mutation round-trip: rename a workspace + create/update/delete a task; verify optimistic update persists across cold restart and Electric reconciles on reconnect
  • Inspect ~/.superset/tanstack-db.sqlite with sqlite3 CLI; confirm 21 tables per active org
  • Org-switching: tables stay isolated, no cross-org bleed
  • No regression on the 6 untouched local-only collections (terminal presets, sidebar state, user prefs)
  • Quit lifecycle clean — no SQLite lockfile / WAL stragglers in ~/.superset/

Follow-ups

  • OrganizationSettings.tsx has the same isLoading pattern over members — settings-only, not on the offline-critical path, deferred
  • Local-only collections → SQLite migration (data-preservation hooks for v2TerminalPresets, v2WorkspaceLocalState, etc.) is a separate, larger PR
  • Alpha library (@tanstack/*-db-sqlite-persistence@0.1.9 is "first alpha release of persistence") — pin exact versions, monitor releases

Summary by cubic

Persist Electric-synced collections to SQLite so cached data survives cold start and the desktop app launches offline. Also removes Tasks view loading guards so cached data renders immediately instead of spinning.

  • New Features

    • Wrapped all 21 Electric collections with persistedCollectionOptions (schemaVersion: 1) using @tanstack/electron-db-sqlite-persistence; data is stored at ~/.superset/tanstack-db.sqlite with org-scoped tables.
    • Wired persistence in main via @tanstack/node-db-sqlite-persistence + better-sqlite3, exposed over existing IPC; lifecycle managed with initTanstackDbPersistence/shutdownTanstackDbPersistence.
    • Local-only collections remain on localStorageCollectionOptions (no migration in this PR).
  • Bug Fixes

    • Removed isLoading guards in Tasks Board/Table and Linear check; views now render when data exists.
    • Close SQLite handle on app quit to prevent leftover WAL/lock files.

Written for commit 5f2646d. Summary will update on new commits. Review in cubic

Summary by CodeRabbit

  • New Features

    • Introduced local data persistence layer for improved reliability and offline support.
  • UI Improvements

    • Refined Tasks view interface with streamlined loading experience.

Wraps all 21 Electric synced collections with @tanstack/electron-db-sqlite-persistence
so the local cache survives cold start and the app's core shell renders offline.
Drops loading-status guards on the Tasks view that were tying spinners to "Electric
has confirmed sync," which kept the page broken until the network responded — now
gated on data presence so cached rows render immediately.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 28, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1d2e37ec-c40b-4400-9b45-ab1f8cc7bcd7

📥 Commits

Reviewing files that changed from the base of the PR and between a8e8556 and 5f2646d.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • apps/desktop/package.json
  • apps/desktop/src/main/index.ts
  • apps/desktop/src/main/lib/persistence/persistence.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/BoardContent/BoardContent.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TableContent/TableContent.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksData/useTasksData.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/useTasksTable.tsx
  • apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts

📝 Walkthrough

Walkthrough

The pull request introduces TanStack SQLite persistence infrastructure to the Electron main process, initializes it during app startup and shutdown, removes loading state UI from task view components, and migrates Electric-synced collections to use SQLite-backed persistence instead of IndexedDB.

Changes

Cohort / File(s) Summary
TanStack SQLite Persistence Setup
apps/desktop/package.json, apps/desktop/src/main/index.ts, apps/desktop/src/main/lib/persistence/persistence.ts
Adds TanStack SQLite persistence packages and implements initialization/shutdown lifecycle hooks in the main process. The persistence module opens a SQLite database at SUPERSET_HOME_DIR/tanstack-db.sqlite, creates a TanStack adapter, and exposes it to the renderer via IPC.
Loading State Removal from Tasks
apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx, components/BoardContent/BoardContent.tsx, components/TableContent/TableContent.tsx, hooks/useTasksData/useTasksData.tsx, hooks/useTasksTable/useTasksTable.tsx
Removes isLoading state and spinner UI from task view components and their hooks. Gating now relies on data resolution (integrations !== undefined) rather than explicit loading flags.
Collections Persistence Migration
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts
Initializes Electron-backed SQLite persistence and switches all Electric-synced per-org collections from IndexedDB to persisted collections with SQLite backend via createPersistedElectricCollection wrapper helper. localStorage-based v2 collections remain unchanged.

Sequence Diagram(s)

sequenceDiagram
    participant MainProcess as Main Process
    participant AppInit as App Initialization
    participant FileSys as File System
    participant SQLiteDB as SQLite Database
    participant TanStackAdapter as TanStack Adapter
    participant IPC as IPC Channel
    participant Renderer as Renderer Process

    AppInit->>MainProcess: Application starts
    MainProcess->>MainProcess: initAppState()
    MainProcess->>MainProcess: initTanstackDbPersistence()
    MainProcess->>FileSys: Ensure SUPERSET_HOME_DIR exists
    MainProcess->>SQLiteDB: Open/create tanstack-db.sqlite
    SQLiteDB-->>MainProcess: Database connection
    MainProcess->>TanStackAdapter: Create SQLite persistence adapter
    MainProcess->>IPC: exposeElectronSQLitePersistence(ipcMain)
    IPC->>Renderer: IPC persistence available
    Renderer-->>IPC: Request persisted collections
    
    rect rgba(200, 150, 255, 0.5)
        note over MainProcess,IPC: Renderer uses persistence for collections
        IPC-->>Renderer: Persistence instance
    end
    
    AppInit->>MainProcess: before-quit event
    MainProcess->>MainProcess: shutdownTanstackDbPersistence()
    MainProcess->>TanStackAdapter: Invoke disposer
    MainProcess->>SQLiteDB: Close connection
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~35 minutes

Poem

🐰 SQLite seeds are planted deep,
Collections now in storage keep,
Loading spinners fade away,
Persisted data here to stay! ✨
IPC channels bright and true,
Electron's magic pulling through! 🚀

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tanstack-db-persistence

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 28, 2026

🚀 Preview Deployment

🔗 Preview Links

Service Status Link
Neon Database (Neon) View Branch
Vercel API (Vercel) Open Preview
Vercel Web (Vercel) Open Preview
Vercel Marketing (Vercel) Open Preview
Vercel Admin (Vercel) Open Preview
Vercel Docs (Vercel) Open Preview

Preview updates automatically with new commits

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 28, 2026

Greptile Summary

This PR persists all 21 Electric-synced collections to SQLite (~/.superset/tanstack-db.sqlite) via @tanstack/electron-db-sqlite-persistence so the desktop shell can render immediately on cold start without waiting for an Electric handshake. It also removes the isLoading spinner guards on the Tasks view, replacing them with data-presence checks so cached rows render offline instead of spinning forever.

Confidence Score: 4/5

Safe to merge with one minor resource-management fix recommended before production: the SQLite database handle should be explicitly closed on shutdown.

All findings are P2. The database-not-closed issue is unlikely to cause data corruption (SQLite is crash-safe, OS releases locks on process exit), but it conflicts with the PR's own stated test goal of no WAL stragglers and could interfere with a future re-init path. The hardcoded schemaVersion is a known limitation documented in the follow-ups. The isLoading → data-presence change is intentional and correct.

apps/desktop/src/main/lib/persistence/persistence.ts — database handle should be stored at module scope and closed in shutdownTanstackDbPersistence

Important Files Changed

Filename Overview
apps/desktop/src/main/lib/persistence/persistence.ts New file: opens better-sqlite3 DB and registers IPC handlers; database handle leaks on shutdown because only the IPC-handler dispose function is stored in module scope
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts Wraps all 21 Electric collections with SQLite persistence via createPersistedElectricCollection; schemaVersion is hardcoded at 1 with no per-collection override path
apps/desktop/src/main/index.ts Integrates initTanstackDbPersistence into app lifecycle (whenReady → before-quit), placement is correct
apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx Replaces isLoading spinner with data-presence check (integrations !== undefined); intentional trade-off to unblock offline rendering
apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksData/useTasksData.tsx Drops isLoading from return type; callers updated consistently
apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/useTasksTable.tsx Drops isLoading from return type; consistent with useTasksData change
apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/BoardContent/BoardContent.tsx Removes isLoading spinner; falls through to empty-state check when no data
apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TableContent/TableContent.tsx Removes isLoading spinner; falls through to empty-state check when no rows

Sequence Diagram

sequenceDiagram
    participant R as Renderer (collections.ts)
    participant IPC as IPC Bridge (window.ipcRenderer)
    participant M as Main Process (persistence.ts)
    participant DB as better-sqlite3 (tanstack-db.sqlite)
    participant E as Electric Server

    Note over M: app.whenReady → initTanstackDbPersistence()
    M->>DB: new Database("tanstack-db.sqlite")
    M->>M: createNodeSQLitePersistence({ database })
    M->>M: exposeElectronSQLitePersistence({ ipcMain, persistence })

    Note over R: Module load → createElectronSQLitePersistence
    R->>R: createPersistedElectricCollection(electricCollectionOptions)

    Note over R: App renders — cold start
    R->>IPC: invoke(tanstack-db:read, { collectionId })
    IPC->>M: IPC channel
    M->>DB: SELECT * FROM collection_table
    DB-->>M: cached rows
    M-->>IPC: rows
    IPC-->>R: cached data (no network needed)
    R->>R: render shell immediately

    Note over R: Electric sync (online)
    R->>E: shape subscription
    E-->>R: up-to-date rows
    R->>IPC: invoke(tanstack-db:write, { collectionId, rows })
    IPC->>M: IPC channel
    M->>DB: UPSERT rows

    Note over M: before-quit → shutdownTanstackDbPersistence()
    M->>M: dispose() — removes IPC handlers
    Note over M,DB: database.close() not called
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/desktop/src/main/lib/persistence/persistence.ts
Line: 11-23

Comment:
**Database handle not closed on shutdown**

`database` is a local variable — only `dispose` (the IPC-handler teardown returned by `exposeElectronSQLitePersistence`) is kept in module scope. Calling `shutdownTanstackDbPersistence` removes the IPC handlers but never calls `database.close()`. On `before-quit` the process then force-exits via `app.exit(0)`. While the OS will release file locks, SQLite may not flush its page cache or checkpoint a WAL file cleanly, which is exactly what the test plan item "No SQLite lockfile / WAL stragglers in `~/.superset/`" is testing for.

Store the database reference at module scope alongside `dispose` so shutdown can close it explicitly:

```ts
let dispose: (() => void) | null = null;
let db: Database.Database | null = null;

export function initTanstackDbPersistence(): void {
  ensureSupersetHomeDirExists();
  db = new Database(join(SUPERSET_HOME_DIR, "tanstack-db.sqlite"));
  const persistence = createNodeSQLitePersistence({ database: db });
  dispose = exposeElectronSQLitePersistence({ ipcMain, persistence });
}

export function shutdownTanstackDbPersistence(): void {
  dispose?.();
  dispose = null;
  db?.close();
  db = null;
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts
Line: 79-91

Comment:
**`schemaVersion` is not overridable per collection**

`createPersistedElectricCollection` hardcodes `schemaVersion: 1` for every collection. If any single collection needs a breaking change (field rename, type change, new `getKey`), the only way to bump it is to change the shared constant — which forces all 21 collections to drop and re-pull their caches simultaneously. This is fine for now, but consider accepting an optional `schemaVersion` parameter so individual collections can be bumped independently when the need arises:

```ts
const createPersistedElectricCollection = ((
  config: ElectricSyncConfig,
  schemaVersion = 1,
) => {
  const persisted = persistedCollectionOptions({
    ...config,
    persistence,
    schemaVersion,
  } as any);
  return createCollection({ ...persisted, ...indexDefaults } as any);
}) as unknown as typeof createCollection;
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "chore(desktop): drop AGENTS.md persisten..." | Re-trigger Greptile

Comment on lines +11 to +23
let dispose: (() => void) | null = null;

export function initTanstackDbPersistence(): void {
ensureSupersetHomeDirExists();
const database = new Database(join(SUPERSET_HOME_DIR, "tanstack-db.sqlite"));
const persistence = createNodeSQLitePersistence({ database });
dispose = exposeElectronSQLitePersistence({ ipcMain, persistence });
}

export function shutdownTanstackDbPersistence(): void {
dispose?.();
dispose = null;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Database handle not closed on shutdown

database is a local variable — only dispose (the IPC-handler teardown returned by exposeElectronSQLitePersistence) is kept in module scope. Calling shutdownTanstackDbPersistence removes the IPC handlers but never calls database.close(). On before-quit the process then force-exits via app.exit(0). While the OS will release file locks, SQLite may not flush its page cache or checkpoint a WAL file cleanly, which is exactly what the test plan item "No SQLite lockfile / WAL stragglers in ~/.superset/" is testing for.

Store the database reference at module scope alongside dispose so shutdown can close it explicitly:

let dispose: (() => void) | null = null;
let db: Database.Database | null = null;

export function initTanstackDbPersistence(): void {
  ensureSupersetHomeDirExists();
  db = new Database(join(SUPERSET_HOME_DIR, "tanstack-db.sqlite"));
  const persistence = createNodeSQLitePersistence({ database: db });
  dispose = exposeElectronSQLitePersistence({ ipcMain, persistence });
}

export function shutdownTanstackDbPersistence(): void {
  dispose?.();
  dispose = null;
  db?.close();
  db = null;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/main/lib/persistence/persistence.ts
Line: 11-23

Comment:
**Database handle not closed on shutdown**

`database` is a local variable — only `dispose` (the IPC-handler teardown returned by `exposeElectronSQLitePersistence`) is kept in module scope. Calling `shutdownTanstackDbPersistence` removes the IPC handlers but never calls `database.close()`. On `before-quit` the process then force-exits via `app.exit(0)`. While the OS will release file locks, SQLite may not flush its page cache or checkpoint a WAL file cleanly, which is exactly what the test plan item "No SQLite lockfile / WAL stragglers in `~/.superset/`" is testing for.

Store the database reference at module scope alongside `dispose` so shutdown can close it explicitly:

```ts
let dispose: (() => void) | null = null;
let db: Database.Database | null = null;

export function initTanstackDbPersistence(): void {
  ensureSupersetHomeDirExists();
  db = new Database(join(SUPERSET_HOME_DIR, "tanstack-db.sqlite"));
  const persistence = createNodeSQLitePersistence({ database: db });
  dispose = exposeElectronSQLitePersistence({ ipcMain, persistence });
}

export function shutdownTanstackDbPersistence(): void {
  dispose?.();
  dispose = null;
  db?.close();
  db = null;
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +79 to +91
const createPersistedElectricCollection = ((config: ElectricSyncConfig) => {
const persisted = persistedCollectionOptions({
...config,
persistence,
schemaVersion: 1,
// biome-ignore lint/suspicious/noExplicitAny: forces sync-wrapped overload
} as any);
return createCollection({
...persisted,
...indexDefaults,
// biome-ignore lint/suspicious/noExplicitAny: persisted utils widen generics
} as any);
}) as unknown as typeof createCollection;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 schemaVersion is not overridable per collection

createPersistedElectricCollection hardcodes schemaVersion: 1 for every collection. If any single collection needs a breaking change (field rename, type change, new getKey), the only way to bump it is to change the shared constant — which forces all 21 collections to drop and re-pull their caches simultaneously. This is fine for now, but consider accepting an optional schemaVersion parameter so individual collections can be bumped independently when the need arises:

const createPersistedElectricCollection = ((
  config: ElectricSyncConfig,
  schemaVersion = 1,
) => {
  const persisted = persistedCollectionOptions({
    ...config,
    persistence,
    schemaVersion,
  } as any);
  return createCollection({ ...persisted, ...indexDefaults } as any);
}) as unknown as typeof createCollection;
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts
Line: 79-91

Comment:
**`schemaVersion` is not overridable per collection**

`createPersistedElectricCollection` hardcodes `schemaVersion: 1` for every collection. If any single collection needs a breaking change (field rename, type change, new `getKey`), the only way to bump it is to change the shared constant — which forces all 21 collections to drop and re-pull their caches simultaneously. This is fine for now, but consider accepting an optional `schemaVersion` parameter so individual collections can be bumped independently when the need arises:

```ts
const createPersistedElectricCollection = ((
  config: ElectricSyncConfig,
  schemaVersion = 1,
) => {
  const persisted = persistedCollectionOptions({
    ...config,
    persistence,
    schemaVersion,
  } as any);
  return createCollection({ ...persisted, ...indexDefaults } as any);
}) as unknown as typeof createCollection;
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 11 files

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