Skip to main content

Command Palette

Search for a command to run...

Using Sqlite with Electron (Electron Forge)

Guide to setup Sqlite with Electron so that it work in both Dev and Production

Updated
5 min read
Using Sqlite with Electron (Electron Forge)

Now before we more forwards here comes the documentation part of Electron , despite how great Forge is there are some underling “secret sauce” of Electron that needed to be taken into account if one would like to understand what's happening.

Well there are quite a many methods that are worth mentioning but in-order to focus on the title I will choose only what concerns us at the moment other will talk about at a later point Hopefully.

Understanding ASAR (Atomic Shell Archive)

Electron apps use something called an ASAR file. In very simple terms, it’s like Electron’s version of a “bundle.”
If you’ve used CRA, everything eventually gets mounted into:

<div id="root"/>

Now, ASAR is not literally like a DOM root, but the idea is similar in spirit:
Electron takes all your JavaScript, HTML, CSS, and assets and packs them into one archive file called:

app.asar

Think of it as a zip file that Electron can read from very quickly.

When your app is packaged, most of your project ends up inside this ASAR.

Now here is the fun part some libraries such as our better-sqlite3 require native modules so they simply cannot reside in this shell.

In order to use better-sqlite we need to make changes to our forge.config.*

import { join } from "path";
import Database from "better-sqlite3";
import { app } from "electron";
import fs from "fs";

export const getDatabase = (filename: string) => {
// app.isPackaged is property that holds true if your app has been packaged
  const isProd = app?.isPackaged || false;
  const dbPath = isProd
    ? join(process.resourcesPath, filename)
    : filename;



  // Ensure directory exists
  const dir = join(dbPath, "..");
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });

  console.log(`Using SQLite database: ${dbPath}`);
  console.log(`Production mode: ${isProd}`);

  // Open the database
  const db = new Database(dbPath);
  db.pragma("journal_mode = WAL");
  db.pragma("foreign_keys = ON");

  return db;
};

// Default DB path (same logic as your original)

const appDataDir = join(
  process.env.APPDATA ||
    (process.platform === "darwin"
      ? join(process.env.HOME!, "Library", "Application Support")
      : join(process.env.HOME!, ".local", "share")),
  "myapp",      // main folder for your app
  "myapp.db"    // SQLite database file
);

// Export a singleton instance
export const db = getDatabase(defaultDbDir);

Next Paste the following in vite.main.config.ts and vite.preload.config.ts

import { defineConfig } from "vite";

export default defineConfig({
    build: {
        sourcemap: true,
        rollupOptions: {
            external: ["better-sqlite3"],
        },
        lib: {
// change the entry based on your structure
            entry: "src/main.ts",
            formats: ["cjs"],
        },
    },
});

Next is our forge.config.ts

import type { ForgeConfig } from '@electron-forge/shared-types';
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
import { MakerZIP } from '@electron-forge/maker-zip';
import { MakerDeb } from '@electron-forge/maker-deb';
import { MakerRpm } from '@electron-forge/maker-rpm';
import { VitePlugin } from '@electron-forge/plugin-vite';
import { FusesPlugin } from '@electron-forge/plugin-fuses';
import { FuseV1Options, FuseVersion } from '@electron/fuses';
import path from "node:path"
import { cp, mkdir } from "node:fs/promises";

const config: ForgeConfig = {
  packagerConfig: {
    asar: {
      unpack: "*.{node,dylib}",
//unpack only unpacks files already inside ASAR,
//but native node_modules are not copied automatically during packaging.
      unpackDir: "{better-sqlite3}",
    },
  },
  rebuildConfig: {
    onlyModules: ["better-sqlite3"],
    force: true,
    platform: process.platform,
    buildFromSource: true,
  },
  makers: [new MakerSquirrel({}), new MakerZIP({}, ['darwin']), new MakerRpm({}), new MakerDeb({})],
  plugins: [
    new VitePlugin({
      // `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc.
      // If you are familiar with Vite configuration, it will look really familiar.
      build: [
        {
          // `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`.
          entry: 'src/main.ts',
          config: 'vite.main.config.ts',
          target: 'main',
        },
        {
          entry: 'src/preload.ts',
          config: 'vite.preload.config.ts',
          target: 'preload',
        },
      ],
      renderer: [
        {
          name: 'main_window',
          config: 'vite.renderer.config.ts',
        },
      ],
    }),
    // Fuses are used to enable/disable various Electron functionality
    // at package time, before code signing the application
    new FusesPlugin({
      version: FuseVersion.V1,
      [FuseV1Options.RunAsNode]: false,
      [FuseV1Options.EnableCookieEncryption]: true,
      [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
      [FuseV1Options.EnableNodeCliInspectArguments]: false,
      [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
      [FuseV1Options.OnlyLoadAppFromAsar]: true,
    }),
  ],
  hooks: {
    async packageAfterCopy(_forgeConfig, buildPath) {
      const requiredNativePackages = [
        "better-sqlite3",
      ];

      const sourceNodeModulesPath = path.resolve(__dirname, "node_modules");
      const destNodeModulesPath = path.resolve(buildPath, "node_modules");

      await Promise.all(
        requiredNativePackages.map(async (packageName) => {
          const sourcePath = path.join(sourceNodeModulesPath, packageName);
          const destPath = path.join(destNodeModulesPath, packageName);

          await mkdir(path.dirname(destPath), { recursive: true });
          await cp(sourcePath, destPath, {
            recursive: true,
            preserveTimestamps: true,
          });
        }),
      );


    },
  },
};

export default config;

BreakDown


async packageAfterCopy(_forgeConfig, buildPath) {

packageAfterCopy is a hook that runs after Electron Forge copies your app’s files to the build directory, but before packaging them into the final installer or app.asar

const requiredNativePackages = [
  "better-sqlite3",
];

Native modules like better-sqlite3 contain compiled binaries, which sometimes are not automatically included in the packaged app.

const sourceNodeModulesPath = path.resolve(__dirname, "node_modules");
const destNodeModulesPath = path.resolve(buildPath, "node_modules");
await Promise.all(
  requiredNativePackages.map(async (packageName) => {
    const sourcePath = path.join(sourceNodeModulesPath, packageName);
    const destPath = path.join(destNodeModulesPath, packageName);

    await mkdir(path.dirname(destPath), { recursive: true });
    await cp(sourcePath, destPath, {
      recursive: true,
      preserveTimestamps: true,
    });
  }),
);
  • Loops over requiredNativePackages (better-sqlite3 here).

  • For each package:

    1. Builds source and destination paths.

    2. Ensures the destination directory exists (mkdir(..., { recursive: true })).

    3. Copies the package recursively from source to destination, preserving timestamps.

Why this is needed

  • Electron packages apps differently than a regular Node.js app. (https://stackoverflow.com/a/2456882)

  • Some native modules (with compiled binaries) don’t get packaged automatically.

  • This hook ensures that better-sqlite3 is included in the packaged app, so your app can run properly.

More from this blog

Blogs

10 posts

Few Things Which I came Across and thought of sharing Might help someone and might not.