Claude Skill

desktop-storage-electron

Persistent storage, SQLite databases, and credential management in Electron apps

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agents-inc-skills-dist_plugins_desktop-storage-electron_skills_desktop-storage-electron-3a51ef5.zip · 16 KB
Part of agents-inc/skills — 130 skills

Install

skills CLI npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/desktop-storage-electron/skills/desktop-storage-electron
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
Git git clone https://github.com/agents-inc/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Electron Storage & Credentials

Quick Guide: Use electron-store for typed JSON preferences (small key-value config with schema validation, migrations, and file watching). Use better-sqlite3 for structured/relational data or anything beyond simple key-value (synchronous, WAL mode, transactions). Use safeStorage for encrypting secrets like tokens and API keys via the OS keychain -- it replaces the deprecated keytar. All persistent data belongs under app.getPath("userData"). Never store secrets in plain JSON files.


<critical_requirements>

CRITICAL: Before Using This Skill

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)

(You MUST use safeStorage.encryptString() / safeStorage.decryptString() for secrets -- never store tokens, API keys, or passwords in plain text or in electron-store without OS-level encryption)

(You MUST store all persistent data under app.getPath("userData") -- never write to the app installation directory, which is replaced on updates)

(You MUST enable WAL mode (PRAGMA journal_mode = WAL) when using better-sqlite3 -- it prevents readers from blocking writers and avoids SQLITE_BUSY errors in multi-window apps)

(You MUST call safeStorage.isEncryptionAvailable() before encrypting -- it returns false before the app ready event and on some Linux configurations)

(You MUST rebuild better-sqlite3 for Electron's Node.js version using @electron/rebuild -- mismatched native bindings crash the app)

</critical_requirements>


Auto-detection: electron-store, better-sqlite3, safeStorage, app.getPath, userData, encryptString, decryptString, isEncryptionAvailable, lowdb, JSONFilePreset, persistent storage, credential storage, keytar replacement, electron config, electron preferences, electron database

When to use:

  • Persisting user preferences and app configuration
  • Storing structured or relational data locally
  • Encrypting tokens, API keys, or other secrets
  • Choosing between storage solutions for an Electron app
  • Migrating stored data between app versions
  • Working with app.getPath() standard directories

When NOT to use:

  • Choosing a UI framework or styling for the renderer (separate skill)
  • IPC communication patterns between main and renderer (separate concern)
  • Packaging and distribution concerns (separate concern)
  • Server-side or cloud storage

Key patterns covered:

  • electron-store: typed config, schema validation, migrations, encryption, watching
  • better-sqlite3: WAL mode, prepared statements, transactions, native module rebuild
  • safeStorage: OS keychain encryption for secrets, replacing keytar
  • lowdb: lightweight JSON database for medium-complexity data
  • Storage path conventions using app.getPath()
  • Credential storage best practices



<decision_framework>

Decision Framework

Choosing a Storage Solution

What kind of data?
|
+-- User preferences / small config (theme, window size, feature flags)?
|   +-- electron-store (JSON file, schema validation, migrations)
|
+-- Secrets (tokens, API keys, passwords)?
|   +-- safeStorage + electron-store or file
|   +-- Never plain text, never unencrypted electron-store
|
+-- Structured / relational data (records, queries, indexes)?
|   +-- better-sqlite3 (WAL mode, transactions, scales to GB)
|
+-- JSON document collections (nested objects, no joins needed)?
|   +-- Small (<10MB) -> lowdb
|   +-- Large or concurrent writes -> better-sqlite3 with JSON columns
|
+-- Temporary / cache data?
|   +-- app.getPath("temp") + regular file I/O
|
+-- Session-only state (lost on quit)?
    +-- In-memory (no persistence needed)

electron-store vs better-sqlite3

Criteria electron-store better-sqlite3
Data shape Flat key-value, small JSON Relational, structured records
Data size < 1MB Up to several GB
Query capability Get by key, dot-notation Full SQL, indexes, joins
Concurrent access Single process only WAL mode supports multi-window
Schema evolution Migrations by semver SQL ALTER TABLE / migration scripts
Setup complexity Zero (pure JS) Native module rebuild required
Best for Preferences, feature flags Chat history, project data, logs

safeStorage vs electron-store encryptionKey

Feature safeStorage electron-store encryptionKey
Security level OS keychain (strong) Obfuscation only (weak)
Key management OS manages keys Key embedded in source code
Use for secrets Yes No -- not actual encryption
Use for obfuscation Overkill Yes -- prevents casual file reading
Platform support macOS, Windows, Linux (varies) All platforms

</decision_framework>


Detailed Resources:

  • examples/core.md - electron-store setup, migrations, watching, safeStorage credential manager, lowdb, storage paths
  • examples/sqlite.md - better-sqlite3 setup, WAL mode, prepared statements, transactions, migrations, native rebuild
  • reference.md - API quick-reference tables, path directory map, security checklist

<red_flags>

RED FLAGS

Critical Security Issues:

  • Storing tokens, API keys, or passwords in plain text (electron-store without safeStorage)
  • Using electron-store's encryptionKey option for actual secrets -- it is obfuscation, not encryption. The key is in your source code.
  • Writing persistent data to the app installation directory -- it is deleted on update
  • Giving renderer processes direct filesystem or database access -- route through IPC

Architecture Issues:

  • Not enabling WAL mode with better-sqlite3 -- causes SQLITE_BUSY errors when reading and writing concurrently
  • Using better-sqlite3 without @electron/rebuild -- native module version mismatch crashes the app at startup
  • Using electron-store for large datasets (>1MB) -- the entire file is read and written on every change
  • Running database operations in the renderer process instead of the main process
  • Not checking safeStorage.isEncryptionAvailable() before encrypting -- crashes on Linux without a secret service

Common Mistakes:

  • Calling safeStorage methods before app.whenReady() -- encryption is unavailable until the app is ready
  • Forgetting to db.close() on before-quit -- risks WAL file corruption
  • Using async functions inside better-sqlite3 transactions -- the transaction commits at the first await, not at function end
  • Not using asarUnpack for better-sqlite3 in packaged builds -- the native binary fails to load from inside ASAR archives
  • Storing Buffer objects directly in electron-store -- they serialize incorrectly. Convert to base64 strings.

Gotchas & Edge Cases:

  • electron-store requires Electron 30+ and is ESM-only (no CommonJS)
  • safeStorage on Windows (DPAPI) protects data per-user but not per-app -- another app running as the same user could theoretically decrypt
  • safeStorage on Linux depends on the desktop environment's secret service (gnome-keyring, KWallet) -- falls back to plaintext if none is available
  • electron-store's schema validation uses JSON Schema draft-2020-12 via ajv -- not Zod
  • Object.groupBy on better-sqlite3 result rows works but rows are plain objects with a null prototype -- use Object.hasOwn() not hasOwnProperty

</red_flags>


<critical_reminders>

CRITICAL REMINDERS

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)

(You MUST use safeStorage.encryptString() / safeStorage.decryptString() for secrets -- never store tokens, API keys, or passwords in plain text or in electron-store without OS-level encryption)

(You MUST store all persistent data under app.getPath("userData") -- never write to the app installation directory, which is replaced on updates)

(You MUST enable WAL mode (PRAGMA journal_mode = WAL) when using better-sqlite3 -- it prevents readers from blocking writers and avoids SQLITE_BUSY errors in multi-window apps)

(You MUST call safeStorage.isEncryptionAvailable() before encrypting -- it returns false before the app ready event and on some Linux configurations)

(You MUST rebuild better-sqlite3 for Electron's Node.js version using @electron/rebuild -- mismatched native bindings crash the app)

Failure to follow these rules will cause data loss, security vulnerabilities, or application crashes.

</critical_reminders>

Files (skills)
  • examples
    • core.md 12.9 KB
      # Electron Storage & Credentials - Core Patterns
      
      > electron-store typed preferences, safeStorage credential management, lowdb JSON database, storage paths. See [SKILL.md](../SKILL.md) for decision frameworks and red flags. See [sqlite.md](sqlite.md) for better-sqlite3 patterns.
      
      ---
      
      ## electron-store: Typed Preferences with Schema
      
      ```typescript
      import Store from "electron-store";
      
      interface AppSettings {
        theme: "light" | "dark" | "system";
        windowBounds: { width: number; height: number; x?: number; y?: number };
        recentFiles: string[];
        fontSize: number;
        lastOpenedProject: string | null;
      }
      
      const DEFAULT_WIDTH = 1200;
      const DEFAULT_HEIGHT = 800;
      const MIN_FONT_SIZE = 8;
      const MAX_FONT_SIZE = 72;
      const DEFAULT_FONT_SIZE = 14;
      const MAX_RECENT_FILES = 10;
      
      const store = new Store<AppSettings>({
        defaults: {
          theme: "system",
          windowBounds: { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT },
          recentFiles: [],
          fontSize: DEFAULT_FONT_SIZE,
          lastOpenedProject: null,
        },
        schema: {
          theme: {
            type: "string",
            enum: ["light", "dark", "system"],
          },
          fontSize: {
            type: "number",
            minimum: MIN_FONT_SIZE,
            maximum: MAX_FONT_SIZE,
          },
        },
      });
      
      // Read values (type-safe via generic)
      const theme = store.get("theme"); // "light" | "dark" | "system"
      const bounds = store.get("windowBounds"); // { width, height, x?, y? }
      
      // Write values (schema-validated at write time)
      store.set("theme", "dark");
      store.set("windowBounds", { width: 1400, height: 900, x: 100, y: 50 });
      
      // Dot-notation access for nested properties
      store.set("windowBounds.width", 1600);
      const width = store.get("windowBounds.width");
      
      // Check existence
      if (store.has("lastOpenedProject")) {
        // ...
      }
      
      // Reset specific keys to defaults
      store.reset("theme", "fontSize");
      
      // Clear all stored data
      store.clear();
      ```
      
      **Why good:** Generic type parameter gives compile-time safety on get/set, schema validates at runtime, dot-notation accesses nested properties without reading the entire object
      
      ---
      
      ## electron-store: Migrations Between Versions
      
      Migrations run automatically when the stored version is below the migration key. Use semver ranges.
      
      ```typescript
      import Store from "electron-store";
      
      interface SettingsV2 {
        appearance: { theme: "light" | "dark" | "system"; fontSize: number };
        editor: { tabSize: number; wordWrap: boolean };
      }
      
      const DEFAULT_FONT_SIZE = 14;
      const DEFAULT_TAB_SIZE = 2;
      
      const store = new Store<SettingsV2>({
        defaults: {
          appearance: { theme: "system", fontSize: DEFAULT_FONT_SIZE },
          editor: { tabSize: DEFAULT_TAB_SIZE, wordWrap: true },
        },
        migrations: {
          // Runs for any version below 1.1.0
          "1.1.0": (store) => {
            // Rename flat "theme" key to nested "appearance.theme"
            const oldTheme = store.get("theme" as never);
            if (oldTheme) {
              store.set("appearance.theme", oldTheme as "light" | "dark" | "system");
              store.delete("theme" as never);
            }
          },
          // Runs for any version below 2.0.0
          "2.0.0": (store) => {
            // Move fontSize into appearance group
            const oldFontSize = store.get("fontSize" as never);
            if (oldFontSize) {
              store.set("appearance.fontSize", oldFontSize as number);
              store.delete("fontSize" as never);
            }
          },
        },
        beforeEachMigration: (store, context) => {
          console.log(
            `Migrating from ${context.fromVersion} to ${context.toVersion}`,
          );
        },
      });
      ```
      
      **Why good:** Migrations are declarative by version, run in order, and execute only once. The `beforeEachMigration` hook enables logging for debugging upgrade issues.
      
      ---
      
      ## electron-store: Watching for Changes
      
      Use `watch: true` to detect external changes (e.g., another process editing the config file) and `onDidChange` to react to specific key changes.
      
      ```typescript
      const store = new Store<AppSettings>({
        defaults: {
          /* ... */
        },
        watch: true, // Enables file-system watching
      });
      
      // Watch a specific key
      const unsubTheme = store.onDidChange("theme", (newValue, oldValue) => {
        applyTheme(newValue);
      });
      
      // Watch any change
      const unsubAny = store.onDidAnyChange((newStore, oldStore) => {
        syncSettingsToRenderers(newStore);
      });
      
      // Clean up when done (e.g., on app quit)
      app.on("before-quit", () => {
        unsubTheme();
        unsubAny();
      });
      ```
      
      **Why good:** Subscription returns an unsubscribe function for deterministic cleanup, `onDidChange` provides both old and new values for comparison
      
      ---
      
      ## electron-store: Expose to Renderer via IPC
      
      electron-store runs in the main process. Renderers access it through IPC handlers.
      
      ```typescript
      // main.ts -- register IPC handlers
      import { ipcMain } from "electron";
      import Store from "electron-store";
      
      const store = new Store<AppSettings>({
        /* ... */
      });
      
      ipcMain.handle("settings:get", (_event, key: string) => {
        return store.get(key as keyof AppSettings);
      });
      
      ipcMain.handle("settings:set", (_event, key: string, value: unknown) => {
        store.set(key as keyof AppSettings, value);
      });
      
      ipcMain.handle("settings:getAll", () => {
        return store.store; // Returns entire config object
      });
      ```
      
      ```typescript
      // preload.ts
      import { contextBridge, ipcRenderer } from "electron/renderer";
      
      contextBridge.exposeInMainWorld("settingsAPI", {
        get: (key: string) => ipcRenderer.invoke("settings:get", key),
        set: (key: string, value: unknown) =>
          ipcRenderer.invoke("settings:set", key, value),
        getAll: () => ipcRenderer.invoke("settings:getAll"),
      });
      ```
      
      ```typescript
      // renderer usage
      const theme = await window.settingsAPI.get("theme");
      await window.settingsAPI.set("theme", "dark");
      ```
      
      **Why good:** Renderer has no direct filesystem access, IPC boundary validates the channel, preload exposes a minimal typed API surface
      
      ---
      
      ## safeStorage: Credential Manager
      
      A complete pattern for storing and retrieving encrypted secrets.
      
      ```typescript
      // credential-manager.ts (main process)
      import { safeStorage, app } from "electron";
      import Store from "electron-store";
      
      const CREDENTIAL_STORE_NAME = "secure-credentials";
      
      interface EncryptedCredentials {
        [key: string]: string; // base64-encoded encrypted buffers
      }
      
      const credentialStore = new Store<EncryptedCredentials>({
        name: CREDENTIAL_STORE_NAME,
      });
      
      function ensureEncryptionAvailable(): void {
        if (!safeStorage.isEncryptionAvailable()) {
          throw new Error(
            "OS encryption not available. On Linux, ensure gnome-keyring or KWallet is running.",
          );
        }
      }
      
      function saveCredential(key: string, secret: string): void {
        ensureEncryptionAvailable();
        const encrypted = safeStorage.encryptString(secret);
        credentialStore.set(key, encrypted.toString("base64"));
      }
      
      function loadCredential(key: string): string | null {
        const stored = credentialStore.get(key);
        if (!stored) return null;
      
        ensureEncryptionAvailable();
        const buffer = Buffer.from(stored, "base64");
        return safeStorage.decryptString(buffer);
      }
      
      function deleteCredential(key: string): void {
        credentialStore.delete(key);
      }
      
      function hasCredential(key: string): boolean {
        return credentialStore.has(key);
      }
      
      export { saveCredential, loadCredential, deleteCredential, hasCredential };
      ```
      
      ```typescript
      // main.ts -- IPC handlers for credentials
      import { ipcMain } from "electron";
      import {
        saveCredential,
        loadCredential,
        deleteCredential,
        hasCredential,
      } from "./credential-manager.js";
      
      ipcMain.handle("credentials:save", (_event, key: string, secret: string) => {
        saveCredential(key, secret);
      });
      
      ipcMain.handle("credentials:load", (_event, key: string) => {
        return loadCredential(key);
      });
      
      ipcMain.handle("credentials:delete", (_event, key: string) => {
        deleteCredential(key);
      });
      
      ipcMain.handle("credentials:has", (_event, key: string) => {
        return hasCredential(key);
      });
      ```
      
      **Why good:** Secrets are encrypted by the OS keychain before touching disk, base64 encoding stores the buffer safely in JSON, availability check prevents crashes on unsupported platforms, IPC boundary keeps the renderer away from direct crypto operations
      
      ---
      
      ## safeStorage: Platform Behavior
      
      ```
      safeStorage.isEncryptionAvailable()
      |
      +-- macOS: true after app ready (Keychain Access)
      |   Encrypted data is per-app -- other apps cannot decrypt without user override
      |
      +-- Windows: true after app ready (DPAPI)
      |   Encrypted data is per-user -- other apps running as the same user could decrypt
      |
      +-- Linux: depends on desktop environment
          +-- GNOME: gnome-keyring (gnome_libsecret backend)
          +-- KDE: KWallet (kwallet5/kwallet6 backend)
          +-- None: basic_text fallback (NOT secure)
          +-- Check: safeStorage.getSelectedStorageBackend() (Linux only)
      ```
      
      **Key points:**
      
      - Always call `isEncryptionAvailable()` before `encryptString()` -- it throws if unavailable
      - On Linux, `setUsePlainTextEncryption(true)` forces an in-memory key as fallback, but this is NOT secure across restarts
      - The async API (`encryptStringAsync` / `decryptStringAsync`) is recommended for new code -- it is non-blocking and supports key rotation
      
      ---
      
      ## lowdb: JSON Document Database
      
      ```typescript
      import { JSONFilePreset } from "lowdb/node";
      import { app } from "electron";
      import path from "node:path";
      
      interface NotesDB {
        notes: Array<{
          id: string;
          title: string;
          content: string;
          createdAt: string;
          updatedAt: string;
          tags: string[];
        }>;
        trash: Array<{ id: string; deletedAt: string }>;
      }
      
      const DB_FILE = "notes.json";
      
      async function openNotesDB(): Promise<
        ReturnType<typeof JSONFilePreset<NotesDB>>
      > {
        return JSONFilePreset<NotesDB>(path.join(app.getPath("userData"), DB_FILE), {
          notes: [],
          trash: [],
        });
      }
      
      // Usage in main process
      const db = await openNotesDB();
      
      // Find
      const note = db.data.notes.find((n) => n.id === targetId);
      
      // Add
      db.data.notes.push({
        id: crypto.randomUUID(),
        title: "New Note",
        content: "",
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString(),
        tags: [],
      });
      await db.write();
      
      // Update (mutate then write)
      const toUpdate = db.data.notes.find((n) => n.id === targetId);
      if (toUpdate) {
        toUpdate.content = "Updated content";
        toUpdate.updatedAt = new Date().toISOString();
        await db.write();
      }
      
      // Delete (move to trash)
      const index = db.data.notes.findIndex((n) => n.id === targetId);
      if (index !== -1) {
        const [removed] = db.data.notes.splice(index, 1);
        db.data.trash.push({ id: removed.id, deletedAt: new Date().toISOString() });
        await db.write();
      }
      ```
      
      **Why good:** Data is plain JavaScript -- use `find`, `filter`, `map`, `splice` directly. Explicit `write()` means reads are free (in-memory). Type-safe with generics.
      
      **Limitations:**
      
      - Entire file loaded into memory -- not suitable for data over ~10-50MB
      - No concurrent write safety -- use only from main process
      - No indexing or query optimization -- linear scans only
      - No built-in migrations
      
      ---
      
      ## Window Bounds Persistence
      
      A complete pattern for saving and restoring window position and size.
      
      ```typescript
      import { BrowserWindow, screen } from "electron";
      import Store from "electron-store";
      
      const DEFAULT_WIDTH = 1200;
      const DEFAULT_HEIGHT = 800;
      
      interface WindowBounds {
        x: number;
        y: number;
        width: number;
        height: number;
        isMaximized: boolean;
      }
      
      const store = new Store<{ windowBounds: WindowBounds }>({
        defaults: {
          windowBounds: {
            x: 0,
            y: 0,
            width: DEFAULT_WIDTH,
            height: DEFAULT_HEIGHT,
            isMaximized: false,
          },
        },
      });
      
      function createWindow(): BrowserWindow {
        const bounds = store.get("windowBounds");
      
        // Validate that saved position is still on a connected display
        const displayBounds = screen.getAllDisplays().some((display) => {
          const { x, y, width, height } = display.bounds;
          return (
            bounds.x >= x &&
            bounds.y >= y &&
            bounds.x < x + width &&
            bounds.y < y + height
          );
        });
      
        const mainWindow = new BrowserWindow({
          ...(displayBounds ? { x: bounds.x, y: bounds.y } : {}), // Omit position if display no longer connected -- OS picks a default
          width: bounds.width,
          height: bounds.height,
          webPreferences: {
            preload: path.join(__dirname, "preload.js"),
          },
        });
      
        if (bounds.isMaximized) {
          mainWindow.maximize();
        }
      
        // Save bounds on move/resize (debounced by electron-store's atomic writes)
        const saveBounds = (): void => {
          if (!mainWindow.isMaximized()) {
            const [x, y] = mainWindow.getPosition();
            const [width, height] = mainWindow.getSize();
            store.set("windowBounds", { x, y, width, height, isMaximized: false });
          }
        };
      
        mainWindow.on("resize", saveBounds);
        mainWindow.on("move", saveBounds);
        mainWindow.on("maximize", () => store.set("windowBounds.isMaximized", true));
        mainWindow.on("unmaximize", () =>
          store.set("windowBounds.isMaximized", false),
        );
      
        return mainWindow;
      }
      ```
      
      **Why good:** Validates saved position against connected displays (prevents off-screen windows when a monitor is disconnected), saves maximize state separately, uses dot-notation for partial updates
      
      ---
      
      See [sqlite.md](sqlite.md) for better-sqlite3 patterns. See [../reference.md](../reference.md) for API quick-reference tables.
      
    • sqlite.md 8.6 KB
      # Electron Storage - better-sqlite3 Patterns
      
      > SQLite database setup, WAL mode, prepared statements, transactions, migrations, native module rebuild. See [SKILL.md](../SKILL.md) for decision frameworks and red flags. See [core.md](core.md) for electron-store and safeStorage.
      
      ---
      
      ## Database Setup with Performance Pragmas
      
      ```typescript
      import Database from "better-sqlite3";
      import { app } from "electron";
      import path from "node:path";
      
      const DB_FILE = "app-data.db";
      const CACHE_SIZE_KB = 64000; // 64MB cache
      
      function openDatabase(): Database.Database {
        const dbPath = path.join(app.getPath("userData"), DB_FILE);
        const db = new Database(dbPath);
      
        // Performance and safety pragmas -- set once per connection
        db.pragma("journal_mode = WAL"); // Concurrent reads during writes
        db.pragma("synchronous = NORMAL"); // Balanced durability and speed
        db.pragma("foreign_keys = ON"); // Enforce referential integrity
        db.pragma(`cache_size = -${CACHE_SIZE_KB}`); // Negative = KB (positive = pages)
        db.pragma("busy_timeout = 5000"); // Wait 5s on lock instead of failing immediately
        db.pragma("wal_autocheckpoint = 1000"); // Default -- checkpoint every 1000 pages
      
        return db;
      }
      
      // Close cleanly on app quit
      app.on("before-quit", () => {
        db.pragma("wal_checkpoint(TRUNCATE)"); // Flush WAL to main file
        db.close();
      });
      ```
      
      **Why good:** WAL mode is critical for multi-window apps (readers do not block writers), `busy_timeout` prevents immediate SQLITE_BUSY errors, clean shutdown truncates the WAL file for smaller backups
      
      ---
      
      ## Prepared Statements
      
      Prepare once, execute many times. Avoids re-parsing SQL on every call.
      
      ```typescript
      // Create table
      db.exec(`
        CREATE TABLE IF NOT EXISTS notes (
          id TEXT PRIMARY KEY,
          title TEXT NOT NULL,
          content TEXT NOT NULL DEFAULT '',
          created_at TEXT NOT NULL DEFAULT (datetime('now')),
          updated_at TEXT NOT NULL DEFAULT (datetime('now'))
        )
      `);
      
      // Prepare reusable statements
      const insertNote = db.prepare(`
        INSERT INTO notes (id, title, content) VALUES (@id, @title, @content)
      `);
      
      const getNote = db.prepare(`
        SELECT * FROM notes WHERE id = ?
      `);
      
      const getAllNotes = db.prepare(`
        SELECT id, title, created_at, updated_at FROM notes ORDER BY updated_at DESC
      `);
      
      const updateNote = db.prepare(`
        UPDATE notes SET title = @title, content = @content, updated_at = datetime('now')
        WHERE id = @id
      `);
      
      const deleteNote = db.prepare(`
        DELETE FROM notes WHERE id = ?
      `);
      
      // Usage
      insertNote.run({ id: crypto.randomUUID(), title: "My Note", content: "Hello" });
      
      const note = getNote.get("abc-123"); // Single row or undefined
      const notes = getAllNotes.all(); // Array of rows
      
      updateNote.run({ id: "abc-123", title: "Updated", content: "New content" });
      deleteNote.run("abc-123");
      ```
      
      **Why good:** Named parameters (`@id`) are self-documenting, positional `?` works for single-parameter queries, `.get()` returns a single row, `.all()` returns an array
      
      ---
      
      ## Transactions for Bulk Operations
      
      Transactions make bulk operations atomic and dramatically faster (50x+ for many inserts).
      
      ```typescript
      interface NoteInput {
        id: string;
        title: string;
        content: string;
      }
      
      const insertNote = db.prepare(`
        INSERT INTO notes (id, title, content) VALUES (@id, @title, @content)
      `);
      
      // Wrap in transaction for atomicity and performance
      const insertMany = db.transaction((notes: NoteInput[]) => {
        for (const note of notes) {
          insertNote.run(note);
        }
        return notes.length;
      });
      
      // All-or-nothing: if any insert fails, all are rolled back
      const count = insertMany([
        { id: "1", title: "Note 1", content: "Content 1" },
        { id: "2", title: "Note 2", content: "Content 2" },
        { id: "3", title: "Note 3", content: "Content 3" },
      ]);
      ```
      
      **Why good:** Without a transaction, each insert is a separate disk write. With a transaction, all writes happen in one disk operation. Automatic rollback on exception.
      
      **Critical:** Transaction functions must be synchronous. Async functions return at the first `await`, which commits the transaction prematurely:
      
      ```typescript
      // BAD: async inside transaction
      const broken = db.transaction(async (data: NoteInput[]) => {
        for (const note of data) {
          await someAsyncValidation(note); // Transaction already committed!
          insertNote.run(note);
        }
      });
      ```
      
      ---
      
      ## Schema Migrations
      
      Run migrations on database open to evolve the schema across app versions.
      
      ```typescript
      const CURRENT_SCHEMA_VERSION = 3;
      
      function runMigrations(db: Database.Database): void {
        const currentVersion = db.pragma("user_version", { simple: true }) as number;
      
        if (currentVersion >= CURRENT_SCHEMA_VERSION) return;
      
        const migrate = db.transaction(() => {
          if (currentVersion < 1) {
            db.exec(`
              CREATE TABLE notes (
                id TEXT PRIMARY KEY,
                title TEXT NOT NULL,
                content TEXT NOT NULL DEFAULT '',
                created_at TEXT NOT NULL DEFAULT (datetime('now'))
              )
            `);
          }
      
          if (currentVersion < 2) {
            db.exec(`ALTER TABLE notes ADD COLUMN updated_at TEXT`);
            db.exec(
              `UPDATE notes SET updated_at = created_at WHERE updated_at IS NULL`,
            );
          }
      
          if (currentVersion < 3) {
            db.exec(
              `ALTER TABLE notes ADD COLUMN archived INTEGER NOT NULL DEFAULT 0`,
            );
            db.exec(`CREATE INDEX idx_notes_archived ON notes(archived)`);
          }
      
          db.pragma(`user_version = ${CURRENT_SCHEMA_VERSION}`);
        });
      
        migrate();
      }
      
      // Usage
      const db = openDatabase();
      runMigrations(db);
      ```
      
      **Why good:** Uses SQLite's `user_version` pragma to track schema version, runs all pending migrations in a single transaction (all-or-nothing), idempotent -- safe to run on every app start
      
      ---
      
      ## Expose to Renderer via IPC
      
      The database lives in the main process. Renderers interact via IPC handlers.
      
      ```typescript
      // main.ts
      import { ipcMain } from "electron";
      
      const db = openDatabase();
      runMigrations(db);
      
      const insertNote = db.prepare(`
        INSERT INTO notes (id, title, content) VALUES (@id, @title, @content)
      `);
      const getNote = db.prepare("SELECT * FROM notes WHERE id = ?");
      const getAllNotes = db.prepare("SELECT * FROM notes ORDER BY updated_at DESC");
      const deleteNote = db.prepare("DELETE FROM notes WHERE id = ?");
      
      ipcMain.handle(
        "notes:create",
        (_event, note: { title: string; content: string }) => {
          const id = crypto.randomUUID();
          insertNote.run({ id, title: note.title, content: note.content });
          return getNote.get(id);
        },
      );
      
      ipcMain.handle("notes:list", () => {
        return getAllNotes.all();
      });
      
      ipcMain.handle("notes:delete", (_event, id: string) => {
        const result = deleteNote.run(id);
        return result.changes > 0; // true if a row was deleted
      });
      ```
      
      ```typescript
      // preload.ts
      import { contextBridge, ipcRenderer } from "electron/renderer";
      
      contextBridge.exposeInMainWorld("notesAPI", {
        create: (note: { title: string; content: string }) =>
          ipcRenderer.invoke("notes:create", note),
        list: () => ipcRenderer.invoke("notes:list"),
        delete: (id: string) => ipcRenderer.invoke("notes:delete", id),
      });
      ```
      
      **Why good:** Renderer has no database access, IPC handlers validate and execute queries in the main process, prepared statements are reused across calls
      
      ---
      
      ## Native Module Rebuild for Electron
      
      better-sqlite3 is a native C++ module that must be compiled against Electron's Node.js version.
      
      ```json
      // package.json
      {
        "dependencies": {
          "better-sqlite3": "^12.8.0"
        },
        "devDependencies": {
          "@electron/rebuild": "^3.7.0"
        },
        "scripts": {
          "postinstall": "electron-rebuild"
        },
        "build": {
          "npmRebuild": true,
          "asarUnpack": ["node_modules/better-sqlite3"]
        }
      }
      ```
      
      **Key points:**
      
      - `better-sqlite3` must be in `dependencies` (not `devDependencies`) -- `@electron/rebuild` skips dev dependencies
      - `asarUnpack` extracts the native binary from the ASAR archive -- it cannot load from inside ASAR
      - The `postinstall` script ensures the native module is rebuilt every time dependencies are installed
      - If using Electron Forge, `@electron/rebuild` is already integrated -- check your forge config
      
      ---
      
      ## In-Memory Database for Tests
      
      ```typescript
      import Database from "better-sqlite3";
      
      function createTestDatabase(): Database.Database {
        const db = new Database(":memory:");
        db.pragma("journal_mode = WAL");
        db.pragma("foreign_keys = ON");
        runMigrations(db); // Apply schema to in-memory DB
        return db;
      }
      
      // Usage in tests
      const db = createTestDatabase();
      // ... run test queries
      db.close();
      ```
      
      **Why good:** In-memory databases are fast, isolated per test, and automatically cleaned up on `close()`. Use the same migration function as production for schema consistency.
      
      ---
      
      See [core.md](core.md) for electron-store and safeStorage patterns. See [../reference.md](../reference.md) for API quick-reference tables.
      
  • reference.md 8.9 KB
    # Electron Storage & Credentials Reference
    
    > Quick-lookup tables, API reference, and security checklist. See [SKILL.md](SKILL.md) for decision frameworks and red flags. See [examples/](examples/) for full code examples.
    
    ---
    
    ## electron-store API Quick Reference
    
    | Method / Property       | Purpose                                  |
    | ----------------------- | ---------------------------------------- |
    | `new Store<T>(options)` | Create store with typed config           |
    | `.get(key, default?)`   | Read value (type-safe)                   |
    | `.set(key, value)`      | Write value (schema-validated)           |
    | `.set(object)`          | Write multiple values                    |
    | `.has(key)`             | Check key existence                      |
    | `.delete(key)`          | Remove key                               |
    | `.reset(...keys)`       | Reset keys to defaults                   |
    | `.clear()`              | Delete all stored data                   |
    | `.onDidChange(key, cb)` | Watch specific key (returns unsubscribe) |
    | `.onDidAnyChange(cb)`   | Watch any change (returns unsubscribe)   |
    | `.store`                | Entire config object                     |
    | `.path`                 | Absolute file path                       |
    | `.size`                 | Number of stored keys                    |
    
    ### Constructor Options
    
    | Option                          | Default                   | Purpose                                                       |
    | ------------------------------- | ------------------------- | ------------------------------------------------------------- |
    | `defaults`                      | `{}`                      | Default values for all keys                                   |
    | `schema`                        | none                      | JSON Schema (draft-2020-12 via ajv) for validation            |
    | `name`                          | `"config"`                | Filename (without extension)                                  |
    | `cwd`                           | `app.getPath("userData")` | Storage directory                                             |
    | `fileExtension`                 | `"json"`                  | File extension                                                |
    | `encryptionKey`                 | none                      | Obfuscation key (NOT security -- use safeStorage for secrets) |
    | `watch`                         | `false`                   | Enable filesystem watching for external changes               |
    | `migrations`                    | none                      | Version-keyed migration handlers                              |
    | `clearInvalidConfig`            | `false`                   | Clear file if it fails schema validation                      |
    | `accessPropertiesByDotNotation` | `true`                    | Enable `store.get("a.b.c")` syntax                            |
    
    ---
    
    ## safeStorage API Quick Reference
    
    | Method                            | Returns   | Notes                               |
    | --------------------------------- | --------- | ----------------------------------- |
    | `isEncryptionAvailable()`         | `boolean` | Must be true before encrypt/decrypt |
    | `encryptString(plainText)`        | `Buffer`  | Throws if unavailable               |
    | `decryptString(encrypted)`        | `string`  | Throws if unavailable or corrupted  |
    | `setUsePlainTextEncryption(bool)` | `void`    | Linux only -- fallback (not secure) |
    | `getSelectedStorageBackend()`     | `string`  | Linux only -- identifies backend    |
    
    ### Platform Encryption Backends
    
    | Platform      | Backend                   | Per-App Isolation                    |
    | ------------- | ------------------------- | ------------------------------------ |
    | macOS         | Keychain Access           | Yes -- other apps need user override |
    | Windows       | DPAPI                     | Per-user only -- not per-app         |
    | Linux (GNOME) | gnome-keyring / libsecret | Yes                                  |
    | Linux (KDE)   | KWallet                   | Yes                                  |
    | Linux (none)  | basic_text (plaintext!)   | No                                   |
    
    ---
    
    ## better-sqlite3 API Quick Reference
    
    | Method                      | Purpose                                        |
    | --------------------------- | ---------------------------------------------- |
    | `new Database(path, opts?)` | Open or create database                        |
    | `.prepare(sql)`             | Create prepared statement                      |
    | `.exec(sql)`                | Execute raw SQL (multiple statements)          |
    | `.pragma(string, opts?)`    | Execute PRAGMA (use `simple: true` for scalar) |
    | `.transaction(fn)`          | Wrap function in transaction                   |
    | `.backup(dest, opts?)`      | Async backup to file (returns Promise)         |
    | `.serialize(opts?)`         | Serialize to Buffer                            |
    | `.close()`                  | Close connection                               |
    
    ### Statement Methods
    
    | Method                | Returns                        | Purpose                                 |
    | --------------------- | ------------------------------ | --------------------------------------- |
    | `.run(...params)`     | `{ changes, lastInsertRowid }` | Execute (INSERT/UPDATE/DELETE)          |
    | `.get(...params)`     | `object \| undefined`          | Single row                              |
    | `.all(...params)`     | `object[]`                     | All matching rows                       |
    | `.iterate(...params)` | `Iterator`                     | Memory-efficient row iteration          |
    | `.pluck(toggle?)`     | `Statement`                    | Return first column only                |
    | `.expand(toggle?)`    | `Statement`                    | Expand to `{ tableName: { col: val } }` |
    | `.bind(...params)`    | `Statement`                    | Pre-bind parameters                     |
    
    ### Recommended Pragmas
    
    | Pragma         | Value    | Purpose                         |
    | -------------- | -------- | ------------------------------- |
    | `journal_mode` | `WAL`    | Concurrent reads during writes  |
    | `synchronous`  | `NORMAL` | Balanced safety/speed           |
    | `foreign_keys` | `ON`     | Enforce referential integrity   |
    | `cache_size`   | `-64000` | 64MB cache (negative = KB)      |
    | `busy_timeout` | `5000`   | Wait on lock instead of failing |
    
    ---
    
    ## app.getPath() Directory Map
    
    | Name        | macOS                                 | Windows                   | Linux                  | Purpose                        |
    | ----------- | ------------------------------------- | ------------------------- | ---------------------- | ------------------------------ |
    | `userData`  | `~/Library/Application Support/<App>` | `%APPDATA%/<App>`         | `~/.config/<App>`      | Config, databases, credentials |
    | `appData`   | `~/Library/Application Support`       | `%APPDATA%`               | `~/.config`            | Parent of userData             |
    | `temp`      | `/tmp`                                | `%TEMP%`                  | `/tmp`                 | Temporary files                |
    | `logs`      | `~/Library/Logs/<App>`                | `%APPDATA%/<App>/logs`    | `~/.config/<App>/logs` | Log files                      |
    | `documents` | `~/Documents`                         | `%USERPROFILE%/Documents` | `~/Documents`          | User documents                 |
    | `downloads` | `~/Downloads`                         | `%USERPROFILE%/Downloads` | `~/Downloads`          | User downloads                 |
    | `desktop`   | `~/Desktop`                           | `%USERPROFILE%/Desktop`   | `~/Desktop`            | User desktop                   |
    | `home`      | `~`                                   | `%USERPROFILE%`           | `~`                    | User home directory            |
    
    ---
    
    ## Storage Security Checklist
    
    - [ ] Secrets encrypted with `safeStorage.encryptString()` before storing
    - [ ] `isEncryptionAvailable()` checked before every encrypt/decrypt call
    - [ ] No tokens, API keys, or passwords in plain-text JSON files
    - [ ] All persistent data under `app.getPath("userData")`, not the install directory
    - [ ] Database closed cleanly on `before-quit` event
    - [ ] WAL mode enabled for better-sqlite3
    - [ ] better-sqlite3 rebuilt for Electron's Node.js version (`@electron/rebuild`)
    - [ ] `asarUnpack` configured for better-sqlite3 in packaged builds
    - [ ] No direct filesystem access from renderer -- all storage routed through IPC
    - [ ] electron-store's `encryptionKey` NOT used as a substitute for `safeStorage`
    
    ---
    
    ## See Also
    
    - [Electron safeStorage Documentation](https://www.electronjs.org/docs/latest/api/safe-storage)
    - [electron-store GitHub](https://github.com/sindresorhus/electron-store)
    - [better-sqlite3 API Documentation](https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md)
    - [lowdb GitHub](https://github.com/typicode/lowdb)
    - [Electron app.getPath() Documentation](https://www.electronjs.org/docs/latest/api/app#appgetpathname)
    
  • SKILL.md 15.7 KB
    ---
    name: desktop-storage-electron
    description: Persistent storage, SQLite databases, and credential management in Electron apps
    ---
    
    # Electron Storage & Credentials
    
    > **Quick Guide:** Use `electron-store` for typed JSON preferences (small key-value config with schema validation, migrations, and file watching). Use `better-sqlite3` for structured/relational data or anything beyond simple key-value (synchronous, WAL mode, transactions). Use `safeStorage` for encrypting secrets like tokens and API keys via the OS keychain -- it replaces the deprecated `keytar`. All persistent data belongs under `app.getPath("userData")`. Never store secrets in plain JSON files.
    
    ---
    
    <critical_requirements>
    
    ## CRITICAL: Before Using This Skill
    
    > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
    
    **(You MUST use `safeStorage.encryptString()` / `safeStorage.decryptString()` for secrets -- never store tokens, API keys, or passwords in plain text or in electron-store without OS-level encryption)**
    
    **(You MUST store all persistent data under `app.getPath("userData")` -- never write to the app installation directory, which is replaced on updates)**
    
    **(You MUST enable WAL mode (`PRAGMA journal_mode = WAL`) when using better-sqlite3 -- it prevents readers from blocking writers and avoids SQLITE_BUSY errors in multi-window apps)**
    
    **(You MUST call `safeStorage.isEncryptionAvailable()` before encrypting -- it returns false before the app `ready` event and on some Linux configurations)**
    
    **(You MUST rebuild better-sqlite3 for Electron's Node.js version using `@electron/rebuild` -- mismatched native bindings crash the app)**
    
    </critical_requirements>
    
    ---
    
    **Auto-detection:** electron-store, better-sqlite3, safeStorage, app.getPath, userData, encryptString, decryptString, isEncryptionAvailable, lowdb, JSONFilePreset, persistent storage, credential storage, keytar replacement, electron config, electron preferences, electron database
    
    **When to use:**
    
    - Persisting user preferences and app configuration
    - Storing structured or relational data locally
    - Encrypting tokens, API keys, or other secrets
    - Choosing between storage solutions for an Electron app
    - Migrating stored data between app versions
    - Working with `app.getPath()` standard directories
    
    **When NOT to use:**
    
    - Choosing a UI framework or styling for the renderer (separate skill)
    - IPC communication patterns between main and renderer (separate concern)
    - Packaging and distribution concerns (separate concern)
    - Server-side or cloud storage
    
    **Key patterns covered:**
    
    - electron-store: typed config, schema validation, migrations, encryption, watching
    - better-sqlite3: WAL mode, prepared statements, transactions, native module rebuild
    - safeStorage: OS keychain encryption for secrets, replacing keytar
    - lowdb: lightweight JSON database for medium-complexity data
    - Storage path conventions using `app.getPath()`
    - Credential storage best practices
    
    ---
    
    <philosophy>
    
    ## Philosophy
    
    Electron apps have access to the full filesystem but should store data in OS-designated locations. The right storage solution depends on data shape and sensitivity:
    
    **Preferences and small config** (theme, window bounds, feature flags): `electron-store` writes a single JSON file atomically. It is read and written in full on every change, so it is only appropriate for small data (under ~1MB).
    
    **Structured or queryable data** (chat history, project metadata, analytics): `better-sqlite3` provides a synchronous SQLite database with ACID transactions. It handles concurrent reads via WAL mode and scales to gigabytes.
    
    **Secrets** (OAuth tokens, API keys, passwords): `safeStorage` uses the OS keychain (macOS Keychain, Windows DPAPI, Linux secret service) to encrypt strings. The encrypted buffer can be stored in electron-store or a file -- only your app can decrypt it on the same machine and user account.
    
    **Medium-complexity JSON data** (todo lists, small document stores): `lowdb` provides a file-backed JavaScript object with native array methods. Simpler than SQLite for JSON-shaped data that does not need relational queries.
    
    **Key principle:** Storage runs in the **main process**. Renderers request data via IPC. Never give renderers direct filesystem or database access.
    
    </philosophy>
    
    ---
    
    <patterns>
    
    ## Core Patterns
    
    ### Pattern 1: electron-store -- Typed Preferences
    
    Use for small key-value configuration that persists across sessions. Supports schema validation, defaults, and migrations.
    
    ```typescript
    import Store from "electron-store";
    
    interface AppSettings {
      theme: "light" | "dark" | "system";
      windowBounds: { width: number; height: number; x?: number; y?: number };
      recentFiles: string[];
      fontSize: number;
    }
    
    const DEFAULT_WIDTH = 1200;
    const DEFAULT_HEIGHT = 800;
    const MIN_FONT_SIZE = 8;
    const MAX_FONT_SIZE = 72;
    const DEFAULT_FONT_SIZE = 14;
    
    const store = new Store<AppSettings>({
      defaults: {
        theme: "system",
        windowBounds: { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT },
        recentFiles: [],
        fontSize: DEFAULT_FONT_SIZE,
      },
      schema: {
        fontSize: {
          type: "number",
          minimum: MIN_FONT_SIZE,
          maximum: MAX_FONT_SIZE,
        },
      },
    });
    ```
    
    **Why good:** Type-safe generic parameter ensures `.get()` and `.set()` are checked at compile time, named constants for all limits, schema rejects invalid values at write time
    
    See [examples/core.md](examples/core.md) for migrations, file watching, dot-notation access, and renderer integration via IPC.
    
    ---
    
    ### Pattern 2: better-sqlite3 -- Local Database
    
    Use for structured data that benefits from queries, indexes, or transactions. Always enable WAL mode.
    
    ```typescript
    import Database from "better-sqlite3";
    import { app } from "electron";
    import path from "node:path";
    
    const DB_FILE = "app-data.db";
    
    const db = new Database(path.join(app.getPath("userData"), DB_FILE));
    
    // Performance pragmas -- set once at connection open
    db.pragma("journal_mode = WAL");
    db.pragma("synchronous = NORMAL");
    db.pragma("foreign_keys = ON");
    ```
    
    **Why good:** WAL mode allows concurrent reads during writes (essential for multi-window apps), `synchronous = NORMAL` balances safety and speed, foreign keys enforce referential integrity
    
    See [examples/sqlite.md](examples/sqlite.md) for prepared statements, transactions, bulk inserts, and schema migrations.
    
    ---
    
    ### Pattern 3: safeStorage -- OS Keychain Encryption
    
    Use for secrets (tokens, API keys, passwords). The encrypted buffer is opaque -- only your app on the same machine and user account can decrypt it.
    
    ```typescript
    import { safeStorage, app } from "electron";
    import Store from "electron-store";
    
    const credentialStore = new Store<Record<string, string>>({
      name: "credentials",
    });
    
    function saveSecret(key: string, plainText: string): void {
      if (!safeStorage.isEncryptionAvailable()) {
        throw new Error("OS encryption is not available");
      }
      const encrypted = safeStorage.encryptString(plainText);
      credentialStore.set(key, encrypted.toString("base64"));
    }
    
    function loadSecret(key: string): string | null {
      const stored = credentialStore.get(key);
      if (!stored) return null;
      const buffer = Buffer.from(stored, "base64");
      return safeStorage.decryptString(buffer);
    }
    ```
    
    **Why good:** Secrets are encrypted via the OS keychain before being persisted, base64 encoding allows storing the buffer in JSON, explicit availability check prevents crashes on unsupported systems
    
    See [examples/core.md](examples/core.md) for the full credential manager pattern and async API usage.
    
    ---
    
    ### Pattern 4: Storage Path Conventions
    
    All persistent data belongs under `app.getPath("userData")`. Use other paths for specific purposes.
    
    ```typescript
    import { app } from "electron";
    
    // User-specific persistent data (config, databases, credentials)
    const userDataDir = app.getPath("userData");
    //  macOS: ~/Library/Application Support/<AppName>
    //  Windows: %APPDATA%/<AppName>
    //  Linux: ~/.config/<AppName>
    
    // Temporary files (cache, downloads in progress)
    const tempDir = app.getPath("temp");
    
    // Log files
    const logsDir = app.getPath("logs");
    
    // User's documents, downloads, desktop (for file save dialogs)
    const documentsDir = app.getPath("documents");
    const downloadsDir = app.getPath("downloads");
    ```
    
    **Key point:** The `userData` directory survives app updates. The app installation directory does not -- writing data there causes data loss on update.
    
    ---
    
    ### Pattern 5: lowdb -- Lightweight JSON Database
    
    Use when data is JSON-shaped but too complex for flat key-value (nested arrays, document collections) and does not need relational queries.
    
    ```typescript
    import { JSONFilePreset } from "lowdb/node";
    import { app } from "electron";
    import path from "node:path";
    
    interface ProjectData {
      projects: Array<{ id: string; name: string; lastOpened: string }>;
      settings: { sortBy: "name" | "lastOpened" };
    }
    
    const DB_FILE = "projects.json";
    const defaultData: ProjectData = {
      projects: [],
      settings: { sortBy: "lastOpened" },
    };
    
    const db = await JSONFilePreset<ProjectData>(
      path.join(app.getPath("userData"), DB_FILE),
      defaultData,
    );
    
    // Read
    const recent = db.data.projects.toSorted((a, b) =>
      b.lastOpened.localeCompare(a.lastOpened),
    );
    
    // Write (mutate then persist)
    db.data.projects.push({
      id: "abc",
      name: "New Project",
      lastOpened: new Date().toISOString(),
    });
    await db.write();
    ```
    
    **Why good:** Plain JavaScript data access (no query language), type-safe with generics, file I/O only on explicit `.write()` call
    
    **When to prefer SQLite instead:** Data exceeds ~10MB, you need indexes or joins, you need concurrent write safety, or you need partial reads (lowdb loads the entire file into memory).
    
    </patterns>
    
    ---
    
    <decision_framework>
    
    ## Decision Framework
    
    ### Choosing a Storage Solution
    
    ```
    What kind of data?
    |
    +-- User preferences / small config (theme, window size, feature flags)?
    |   +-- electron-store (JSON file, schema validation, migrations)
    |
    +-- Secrets (tokens, API keys, passwords)?
    |   +-- safeStorage + electron-store or file
    |   +-- Never plain text, never unencrypted electron-store
    |
    +-- Structured / relational data (records, queries, indexes)?
    |   +-- better-sqlite3 (WAL mode, transactions, scales to GB)
    |
    +-- JSON document collections (nested objects, no joins needed)?
    |   +-- Small (<10MB) -> lowdb
    |   +-- Large or concurrent writes -> better-sqlite3 with JSON columns
    |
    +-- Temporary / cache data?
    |   +-- app.getPath("temp") + regular file I/O
    |
    +-- Session-only state (lost on quit)?
        +-- In-memory (no persistence needed)
    ```
    
    ### electron-store vs better-sqlite3
    
    | Criteria          | electron-store             | better-sqlite3                      |
    | ----------------- | -------------------------- | ----------------------------------- |
    | Data shape        | Flat key-value, small JSON | Relational, structured records      |
    | Data size         | < 1MB                      | Up to several GB                    |
    | Query capability  | Get by key, dot-notation   | Full SQL, indexes, joins            |
    | Concurrent access | Single process only        | WAL mode supports multi-window      |
    | Schema evolution  | Migrations by semver       | SQL ALTER TABLE / migration scripts |
    | Setup complexity  | Zero (pure JS)             | Native module rebuild required      |
    | Best for          | Preferences, feature flags | Chat history, project data, logs    |
    
    ### safeStorage vs electron-store encryptionKey
    
    | Feature             | safeStorage                    | electron-store encryptionKey        |
    | ------------------- | ------------------------------ | ----------------------------------- |
    | Security level      | OS keychain (strong)           | Obfuscation only (weak)             |
    | Key management      | OS manages keys                | Key embedded in source code         |
    | Use for secrets     | Yes                            | No -- not actual encryption         |
    | Use for obfuscation | Overkill                       | Yes -- prevents casual file reading |
    | Platform support    | macOS, Windows, Linux (varies) | All platforms                       |
    
    </decision_framework>
    
    ---
    
    **Detailed Resources:**
    
    - [examples/core.md](examples/core.md) - electron-store setup, migrations, watching, safeStorage credential manager, lowdb, storage paths
    - [examples/sqlite.md](examples/sqlite.md) - better-sqlite3 setup, WAL mode, prepared statements, transactions, migrations, native rebuild
    - [reference.md](reference.md) - API quick-reference tables, path directory map, security checklist
    
    ---
    
    <red_flags>
    
    ## RED FLAGS
    
    **Critical Security Issues:**
    
    - Storing tokens, API keys, or passwords in plain text (electron-store without safeStorage)
    - Using `electron-store`'s `encryptionKey` option for actual secrets -- it is obfuscation, not encryption. The key is in your source code.
    - Writing persistent data to the app installation directory -- it is deleted on update
    - Giving renderer processes direct filesystem or database access -- route through IPC
    
    **Architecture Issues:**
    
    - Not enabling WAL mode with better-sqlite3 -- causes `SQLITE_BUSY` errors when reading and writing concurrently
    - Using better-sqlite3 without `@electron/rebuild` -- native module version mismatch crashes the app at startup
    - Using electron-store for large datasets (>1MB) -- the entire file is read and written on every change
    - Running database operations in the renderer process instead of the main process
    - Not checking `safeStorage.isEncryptionAvailable()` before encrypting -- crashes on Linux without a secret service
    
    **Common Mistakes:**
    
    - Calling `safeStorage` methods before `app.whenReady()` -- encryption is unavailable until the app is ready
    - Forgetting to `db.close()` on `before-quit` -- risks WAL file corruption
    - Using async functions inside `better-sqlite3` transactions -- the transaction commits at the first `await`, not at function end
    - Not using `asarUnpack` for better-sqlite3 in packaged builds -- the native binary fails to load from inside ASAR archives
    - Storing `Buffer` objects directly in electron-store -- they serialize incorrectly. Convert to base64 strings.
    
    **Gotchas & Edge Cases:**
    
    - `electron-store` requires Electron 30+ and is ESM-only (no CommonJS)
    - `safeStorage` on Windows (DPAPI) protects data per-user but not per-app -- another app running as the same user could theoretically decrypt
    - `safeStorage` on Linux depends on the desktop environment's secret service (gnome-keyring, KWallet) -- falls back to plaintext if none is available
    - `electron-store`'s `schema` validation uses JSON Schema draft-2020-12 via ajv -- not Zod
    - `Object.groupBy` on `better-sqlite3` result rows works but rows are plain objects with a null prototype -- use `Object.hasOwn()` not `hasOwnProperty`
    
    </red_flags>
    
    ---
    
    <critical_reminders>
    
    ## CRITICAL REMINDERS
    
    > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
    
    **(You MUST use `safeStorage.encryptString()` / `safeStorage.decryptString()` for secrets -- never store tokens, API keys, or passwords in plain text or in electron-store without OS-level encryption)**
    
    **(You MUST store all persistent data under `app.getPath("userData")` -- never write to the app installation directory, which is replaced on updates)**
    
    **(You MUST enable WAL mode (`PRAGMA journal_mode = WAL`) when using better-sqlite3 -- it prevents readers from blocking writers and avoids SQLITE_BUSY errors in multi-window apps)**
    
    **(You MUST call `safeStorage.isEncryptionAvailable()` before encrypting -- it returns false before the app `ready` event and on some Linux configurations)**
    
    **(You MUST rebuild better-sqlite3 for Electron's Node.js version using `@electron/rebuild` -- mismatched native bindings crash the app)**
    
    **Failure to follow these rules will cause data loss, security vulnerabilities, or application crashes.**
    
    </critical_reminders>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related