Intégrer SQLite dans Obsidian.md


Introduction

Pour mes notes personnelles, j’ai choisi l’application Obsidian.md car elle me permet de rédiger et d’organiser mes notes directement sur mon disque dur et de choisir le service de sauvegarde cloud.
Cependant, Obsidian ne fournit pas de fonction de base de données telle que proposé par d’autres applications comme Notion.so ou Confluence.
Je me suis alors interrogé sur l’intégration du moteur de base de données SQLite sous forme de plugin dans Obsidian.
Mon choix s’est porté sur SQLite car la base de données est stockée dans un fichier unique. Cela m’offre l’avantage d’avoir mon fichier de base de données côte à côte avec mes notes dans mon coffre Obsidian.

Choix techniques

Pour intégrer SQLite, je choisis la bibliothèque better-sqlite3, reconnue pour sa popularité et sa maintenance active. Elle fournit le moteur SQLite sous la forme d’une extension binaire pré-compilée pour Node.js.
Obsidian.md étant développée avec la plateforme Electron, l’environnement d’exécution Node.js est accessible et permet donc le chargement de cette extension native.
Il est techniquement possible d’exécuter SQLite dans le moteur de rendu Chromium intégré à Electron.
Cependant, je pense qu’il est préférable d’exécuter les opérations de base de donnés dans un processus d’arrière plan pour ne pas surchargé le moteur de rendu.

Contraintes techniques

Pour que l’extension native SQLite puisse fonctionner sur toutes les plateformes supportées (Windows, Mac, Linux), nous devons mettre en place un mécanisme de détection et de téléchargement de l’extension en fonction de l’environnement d’exécution.

Analyse de faisabilité

Les chapitres suivants décrivent les étapes de validation de la faisabilité du projet.

Démarrer un processus enfant Node.js

Un plugin Obsidian s’exécute dans le moteur de rendu Chromium. En explorant les fonctionnalités API accessibles, j’ai découvert que nous avions accès à electron ainsi qu’à l’objet utilityProcess qui nous permet de démarrer un processus enfant avec le moteur d’exécution Node.js.

Snippet showing how to fork a Node.js process
const child = electron.remote.utilityProcess.fork(
'<absolute-path>/my-code.js',
[/** arguments */],
{ serviceName: 'obsidian-db-service', stdio: 'pipe' }
)
)

Communication IPC

En utilisant utilityProcess.fork, electron ouvre automatiquement les canaux de communication inter-processus. Ce qui nous permet d’échanger des messages entre le moteur de rendu Chromium et le processus d’arrière plan Node.js.

exemple d'échange de messages
/** Chromium **/
child.postMessage({ data: "hello from Chromium" });
child.on("message", (message) => {
// handle message
});
/** Node.js **/
process.parentPort?.on("message", (message) => {
// handle message here
});
process.paentPort?.postMessage("hello from Node.js");

Comment distribuer une extension native avec mon plugin

Les plugin Obsidian sont chargés et exécuté par le moteur de rendu Chromium. Par conséquent, on ne peut pas compter sur un gestionnaire de paquet pour installer l’extension native requise. Pour gérer cette contrainte, je me suis inspiré des méthodes de distribution utilisées par les développeurs de better-sqlite3.
Dans le code source de la bibliothèque, on constate qu’ils utilisent prebuild-install pour le téléchargement du binaire et bindings pour le chargement de l’extension.

better-sqlite3/lib/database.js
// Load the native addon
let addon;
if (nativeBinding == null) {
addon =
DEFAULT_ADDON ||
(DEFAULT_ADDON = require("bindings")("better_sqlite3.node"));
} else if (typeof nativeBinding === "string") {
// See <https://webpack.js.org/api/module-variables/#__non_webpack_require__-webpack-specific>
const requireFunc =
typeof __non_webpack_require__ === "function"
? __non_webpack_require__
: require;
addon = requireFunc(
path.resolve(nativeBinding).replace(/(\.node)?$/, ".node")
);
} else {
// See <https://github.com/WiseLibs/better-sqlite3/issues/972>
addon = nativeBinding;
}
{
"scripts": {
"install": "prebuild-install || node-gyp rebuild --release"
}

Pour que mon plugin puisse fonctionner sur tous les systèmes d’exploitations supportés par Obsidian. Il me faut implémenter des mécanismes de téléchargement et de chargement similaires.

Mécanisme de téléchargement

Le téléchargement du binaire s’effectue grâce au module prebuild-install qui implémente cette logique en deux étapes qu’on retrouve dans les fonctions ci-dessous.

  • getDownloadUrl node_modules/prebuild-install/util.js
    Génère l’url vers le binaire correspondant dans les artefacts de release sur github. L’url générée prend on considération l’environnement d’exécution.
    L’url ressemblera à https://api.github.com/repose/WiseLibs/better-sqlite3/releases/{name}-v{version}-{runtime}-v{abi}-{platform}{libc}-{arch}.tar.gz
  • downloadPrebuild node_modules/prebuild-install/download.js
    Télécharge le binaire et le décompresse si nécessaire.

On peut rapidement confirmer le téléchargement avec le script suivant.
better-sqlite3-extension-download.js
import { arch, platform } from "os";
import { promisify } from "util";
//@ts-expect-error - no types available
import { getDownloadUrl } from "prebuild-install/util";
//@ts-expect-error - no types available
import { default as downloadPrebuild } from "prebuild-install/download";
const downloadPrebuildAsync = promisify(downloadPrebuild);
const log = console;
const runtime = process.versions.electron ? "electron" : "node";
/**
* Finding the right options required
* reverse engineering 'prebuild-install' code
**/
const opts = {
platform: platform(),
arch: arch(),
"tag-prefix": "v",
runtime: runtime,
path: "./",
pkg: {
name: "better-sqlite3",
version: "12.2.0",
repository: {
type: "git",
url: "git://github.com/WiseLibs/better-sqlite3.git",
},
},
};
const url = getDownloadUrl(opts);
downloadPrebuildAsync(url, opts)
.then(() => {
log.info("Downloaded better-sqlite3");
})
.catch((err: any) => {
log.error("Error downloading better-sqlite3", err);
});