Journaux liées à cette note :

Publication du projet 33 - "POC serveur Git HTTP qui injecte du contenu dans OpenSearch" #iteration, #git, #node, #SvelteKit, #projet, #projet-33, #headless-cms, #POC, #ElasticSearch

Je viens de terminer le "Projet 33 - "POC serveur Git HTTP qui injecte du contenu dans OpenSearch"" en 25h.
Si j'inclus le travail préliminaire du Projet 32 - "POC serveur Git HTTP avec exécution de scripts au push", cela représente 34h au total.

Voici le repository avec le résultat final : https://github.com/stephane-klein/poc-content-repository-git-to-opensearch.

J'ai réussi à implémenter preque tous les éléments que j'avais prévu :

  • Un serveur Git HTTP supportant les opérations push et pull
  • Après chaque git push, injection automatique des données reçues vers une base de données OpenSearch
  • Intégration d'un système de job queue minimaliste qui permet de traiter les tâches d'importation des données Git vers OpenSearch de manière asynchrone. Cela permet entre autres de rendre l'opération git push non bloquante.
  • Le modèle de données doit permettre l'accès au contenu de plusieurs branches.
  • Upload des fichiers binaires vers un serveur Minio tout concervant leurs metadata (chemin, branche, etc) dans OpenSearch.
  • La suppression d'une branche ou d'un commit doit aussi supprimer les données présentes dans OpenSearch et Minio.
  • Utilisation de la librairie nodegit.

source

Le seul élément que je n'ai pas testé est celui-ci :

  • L'accès aux données via l'API de OpenSearch ne doit pas être perturbé pendant les phases d'importation de données depuis Git.

Je précise d'emblée que l'implémentation de la fonctionnalité d'exploration web du content repository manque actuellement d'élégance.

Les dossiers suivants contiennent une quantité importante de code dupliqué :

src/routes
├── branches
│   ├── [branch_name]
│   │   ├── history
│   │   │   ├── +page.server.js
│   │   │   └── +page.svelte
│   │   ├── +page.server.js
│   │   ├── +page.svelte
│   │   └── [...pathname]
│   │       ├── +page.server.js
│   │       └── +page.svelte
│   ├── +page.server.js
│   └── +page.svelte
├── +page.server.js
├── +page.svelte
├── [...pathname]
│   ├── +page.server.js
│   ├── +page.svelte
│   └── raw
│       └── +server.js
└── r
    ├── +page.server.js
    └── [revision]
        ├── history
        │   ├── +page.server.js
        │   └── +page.svelte
        ├── +page.server.js
        ├── +page.svelte
        └── [...pathname]
            ├── +page.server.js
            ├── +page.svelte
            └── raw

Pour le moment, je n'ai pas encore trouvé comment éviter cette duplication de manière élégante.

J'ai pensé à 3 approches pour améliorer cette implémentation :

  • Factoriser la logique de query des fichiers +page.server.js dans une fonction partagée.
  • Migrer complètement ces pages d'exploration vers src/hooks.server.js (avec les Server hooks de SvelteKit ).

Comme cette partie n'était pas au cœur du projet, j'ai préféré ne pas y investir davantage de temps.


Dans ce projet, j'ai utilisé pour la première fois OpenSearch, le fork de Elasticsearch. J'ai dû faire quelques adaptations par rapport à Elasticsearch mais rien de vraiment complexe.

J'ai utilisé la librairie @opensearch-project/opensearch avec succès, bien aidé par Claude Sonnet 4 pour écrire mes query OpenSearch.

J'aimerais mieux maîtriser l'api de OpenSearch et Elasticsearch, mais je ne les utilise pas suffisamment.

Cette dépendance à un LLM pour écrire ces requêtes me contrarie, je me sens prolétaire et j'ai le sentiment de perdre l'habitude de l'effort. Je pense à cette recherche "Your Brain on ChatGPT: Accumulation of Cognitive Debt when Using an AI Assistant for Essay Writing Task" et cela me préoccupe.


J'ai développé un système de job queue minimaliste en NodeJS avec une persistance basée sur des fichiers json simples : src/lib/server/job-queue.js.

Ma recherche avec Claude Sonnet 4 n'a révélé aucune librairie minimaliste existante qui se contente de fichiers pour la persistance.

Cette implémentation me paraît suffisamment robuste pour répondre à l'objectif que je me suis fixé.


J'ai implémenté la fonction importRevision avec nodegit pour parcourir toutes les entrées d'une révision Git du repository et les importer dans OpenSearch.

Claude Sonnet 4 m'a encore été d'une grande aide, me permettant d'éviter de passer trop de temps dans la documentation d'API de NodeGit, qui reste assez minimaliste.

Mon expérience de 2015 avec git2go sur le projet CmsHub avait été nettement plus laborieuse, à l'époque pré-LLM. Cela dit, j'avais quand même réussi. 🙂


L'implémentation du endpoint /src/routes/post_recieve_hook_url/+server.js n'a pas été très difficile.

J'ai réussi à implémenter le support de git push --force sans trop de difficulté.


Qu'est-ce qui t'a amené à choisir OpenSearch pour ce projet, plutôt qu'un autre type de base de données ?

Suite à de multiples expérimentations durant l'été 2024 (voir 2024-08-17_1253 ou Projet 5), j'ai sélectionné Elasticsearch comme moteur de base de données pour sklein-pkm-engine.

La puissance du moteur de query d'Elasticsearch m'a vraiment séduit, comme on peut le voir dans cette implémentation. Ça me paraît beaucoup plus souple que ce que j'avais développé avec postgres-tags-model-poc.

J'ai donc décidé d'explorer les possibilités d'Elasticsearch ou de son fork OpenSearch comme moteur de base de données de content repository. J'ai décidé d'en faire mon option par défaut tant que je ne rencontre pas d'obstacle majeur ou de point bloquant.


La partie où j'ai le plus hésité concerne le choix du modèle de données OpenSearch pour stocker efficacement le versioning Git.

J'ai décidé d'utiliser deux indexes distincts : files et commits :

await client.indices.create({
	index: "files",
	body: {
		mappings: {
			properties: {
				content: {
					type: "text"
				},
				mimetype: {
					type: 'keyword'
				},
				commits: {
					type: 'object',
					dynamic: 'true'
				}
			}
		}
	}
});
await client.indices.create({
	index: "commits",
	body: {
		mappings: {
			properties: {
				index: {
					type: 'integer'
				},
				time: {
					type: 'date',
					format: 'epoch_second'
				},
				message: {
					type: "text"
				},
				parents: {
					type: 'keyword'
				},
				entries: {
					type: 'object',
					dynamic: 'true',
				},
				branches: {
					type: 'keyword'
				}
			}
		}
	}
});

Après import des données depuis le repository dummy-content-repository-solar-system, voici ce qu'on trouve dans files :

[
  {
    _index: 'files',
    _id: '2f729046cb0f02820226c1183aa04ab20ceb857d',
    _score: 1,
    _source: {
      commits: {
        '4da69e469145fe5603e57b9e22889738d066a5e2': 'mars.md',
        d9bffc3da0c91366dda54fefa01383b109554054: 'mars.md'
      },
      mimetype: 'text/markdown; charset=utf-8'
    }
  },
  {
    _index: 'files',
    _id: '1be731144f49282c43b5e7827bef986a52723a71',
    _score: 1,
    _source: {
      commits: {
        '4da69e469145fe5603e57b9e22889738d066a5e2': 'venus.md',
        d9bffc3da0c91366dda54fefa01383b109554054: 'venus.md'
      },
      mimetype: 'text/markdown; charset=utf-8'
    }
  },
  {
    _index: 'files',
    _id: 'ccc921b7a66f18e98f4887189824eefe83c7e0b3',
    _score: 1,
    _source: {
      commits: {
        '4da69e469145fe5603e57b9e22889738d066a5e2': 'terre/index.md',
        a9272695d179e70cca15e89f1632b8fb76112dca: 'terre/index.md',
        d9bffc3da0c91366dda54fefa01383b109554054: 'terre/index.md'
      },
      mimetype: 'text/markdown; charset=utf-8'
    }
  },
  {
    _index: 'files',
    _id: '153d9d6e9dfedb253c624c9f25fbdb7d8691a042',
    _score: 1,
    _source: {
      commits: {
        '4da69e469145fe5603e57b9e22889738d066a5e2': 'terre/lune.md',
        a9272695d179e70cca15e89f1632b8fb76112dca: 'terre/lune.md',
        d9bffc3da0c91366dda54fefa01383b109554054: 'terre/lune.md'
      },
      mimetype: 'text/markdown; charset=utf-8'
    }
  },
  {
    _index: 'files',
    _id: '97ef5b8f52f85c595bf17fac6cbec856ce80bd4a',
    _score: 1,
    _source: {
      commits: { '4da69e469145fe5603e57b9e22889738d066a5e2': 'terre/terre.jpg' },
      mimetype: 'image/jpeg'
    }
  }
]

et voici un exemple de contenu de commits :

[
  {
    _index: 'commits',
    _id: '7ce2ab6f8d29fec0348342d95bfe71899dcb44fa',
    _score: 1,
    _source: { index: 1, time: 1757420855, branches: [ 'main' ], parents: [] }
  },
  {
    _index: 'commits',
    _id: '4da69e469145fe5603e57b9e22889738d066a5e2',
    _score: 1,
    _source: {
      entries: {
        'venus.md': {
          oid: '1be731144f49282c43b5e7827bef986a52723a71',
          contentType: 'text/markdown; charset=utf-8'
        },
        'terre/lune.md': {
          oid: '153d9d6e9dfedb253c624c9f25fbdb7d8691a042',
          contentType: 'text/markdown; charset=utf-8'
        },
        'mars.md': {
          oid: '2f729046cb0f02820226c1183aa04ab20ceb857d',
          contentType: 'text/markdown; charset=utf-8'
        },
        'terre/terre.jpg': {
          oid: '97ef5b8f52f85c595bf17fac6cbec856ce80bd4a',
          contentType: 'image/jpeg'
        },
        'terre/index.md': {
          oid: 'ccc921b7a66f18e98f4887189824eefe83c7e0b3',
          contentType: 'text/markdown; charset=utf-8'
        }
      },
      index: 4,
      time: 1757429173,
      branches: [ 'main' ],
      parents: [ 'd9bffc3da0c91366dda54fefa01383b109554054' ]
    }
  },
  {
    _index: 'commits',
    _id: 'd9bffc3da0c91366dda54fefa01383b109554054',
    _score: 1,
    _source: {
      entries: {
        'venus.md': {
          oid: '1be731144f49282c43b5e7827bef986a52723a71',
          contentType: 'text/markdown; charset=utf-8'
        },
        'terre/lune.md': {
          oid: '153d9d6e9dfedb253c624c9f25fbdb7d8691a042',
          contentType: 'text/markdown; charset=utf-8'
        },
        'mars.md': {
          oid: '2f729046cb0f02820226c1183aa04ab20ceb857d',
          contentType: 'text/markdown; charset=utf-8'
        },
        'terre/index.md': {
          oid: 'ccc921b7a66f18e98f4887189824eefe83c7e0b3',
          contentType: 'text/markdown; charset=utf-8'
        }
      },
      index: 3,
      time: 1757421171,
      branches: [ 'main' ],
      parents: [ 'a9272695d179e70cca15e89f1632b8fb76112dca' ]
    }
  },
  {
    _index: 'commits',
    _id: 'a9272695d179e70cca15e89f1632b8fb76112dca',
    _score: 1,
    _source: {
      entries: {
        'terre/lune.md': {
          oid: '153d9d6e9dfedb253c624c9f25fbdb7d8691a042',
          contentType: 'text/markdown; charset=utf-8'
        },
        'terre/index.md': {
          oid: 'ccc921b7a66f18e98f4887189824eefe83c7e0b3',
          contentType: 'text/markdown; charset=utf-8'
        }
      },
      index: 2,
      time: 1757420956,
      branches: [ 'main' ],
      parents: [ '7ce2ab6f8d29fec0348342d95bfe71899dcb44fa' ]
    }
  }
]

Ensuite, je mise beaucoup sur la puissance du moteur de requête d'OpenSearch pour récupérer efficacement les données à afficher.
Voici l'exemple de src/routes/[...pathname]/+page.server.js qui permet d'afficher le contenu d'un fichier de la branche main.

Première requête :

const responseOid = await client().search({
	index: 'commits',
	body: {
		query: {
			bool: {
				must: [
					{
						term: {
							branches: 'main'
						}
					},
					{
						exists: {
							field: `entries.${params.pathname}`
						}
					}
				]
			}
		},
		_source: [`entries.${params.pathname}`]
	}
});

Seconde requête qui utilise la réponse de la première :

const responseFile = await client().get({
	index: 'files',
	id: responseOid.body.hits.hits[0]._source.entries[params.pathname].oid,
	_source: ['content', 'mimetype']
});

Basé sur l'expérience de ce projet, je souhaite améliorer sklein-pkm-engine pour permettre la mise à jour de notes.sklein.xyz avec mes données locales uniquement via git push, sans avoir besoin d'installer quoi que ce soit sur ma workstation.

Je pense que cette implémentation sera bien plus simple que le Projet 33, car je ne prévois pas d'inclure le support dans un premier temps. Peut-être que je supporterai les branches dans un second temps.