Les sources

Une application a d'ordinaire une base et ne déclare rien. Quand elle en a plusieurs — une archive héritée, le réplica d'un partenaire, un entrepôt — sources: dit quelles entités vivent où, et reads: dit quelles Fronds peuvent interroger à travers.

Où vivent les lignes

sources nomme les endroits qui ne sont pas celui par défaut. Une entité qu'il ne nomme pas reste dans db, donc une application à une seule base se comporte exactement comme avant :

// fougere.config.ts
export default defineFougere({
  db: { path: '.data/app.db' },
  sources: {
    archive: { path: '/mnt/legacy/catalog.db', entities: ['Book'] },
  },
});

Book lit et écrit désormais dans archive ; tout le reste reste dans db. Le port Storage qu'un handler reçoit ne change pas : le placement est un fait sur l'application, pas sur le code qui s'en sert. Une source peut aussi être un moteur que l'appelant a construit — un pool que Fougere n'a pas ouvert reste un endroit où des lignes peuvent vivre.

Une dérivation ne fabrique pas de table à elle, sauf si elle dit .anchor(). C'est le mot de l'entité, jamais une clé de ce fichier : un frond reste montable sans que son hôte sache que l'un de ses schémas détient des lignes.

Ce qui réalise une source

source: nomme l'adaptateur, et ce qui est en dessous appartient à cet adaptateur — la forme qu'adapters: a déjà sur une entité. dialect est une propriété de SQL et y reste, parce que seul @fougere/adapter-sql sait ce qu'elle vaut.

export default defineFougere({
  db: { path: '.data/app.db' },                             // `source: 'sql'` par convention
  sources: {
    archive: { source: 'file', path: './rows', entities: ['Snapshot'] },
    cache:   { source: 'memory', entities: ['Draft'] },
  },
});

Un adaptateur répond à un nom en s'enregistrant à l'import, donc rien de central ne les liste, et un nom auquel rien ne répond est refusé en disant ce que ce process répond. Trois sont livrés : sql (SQLite depuis un nom ; un autre moteur se construit en code et se transmet), file (un JSON par ligne, un répertoire par entité) et memory (une Map, qui est aussi le repli d'une app sans db).

Une source énonce quatre gestes, dont seul le premier est requis :

storageFactorybâtir le stockage d'une entité qui vit ici
migrate?mettre à jour la forme de ce qu'elle tient
transacted?exécuter une unité de travail
close?relâcher

L'absence répond aussi. Une source qui ne rend aucune transaction fait qu'un frame compense au lieu de transiger, et le boot dit laquelle des deux il a construite :

RateCard+Ledger|RateMirrorTogether — compensated: rateCard in 'archive', ledger in 'db' — no isolation
Account+LedgerTogether            — transaction, source 'db'

Deux frames, une app, deux garanties — et les handlers sont les mêmes dans les deux cas.

En écrire un

Un adaptateur fournit Rows, quatre gestes sur une collection indexée, et storageOver en dérive les treize du port de stockage. Les pages, les critères et les tampons du cycle de vie ne s'écrivent pas deux fois :

import { Sources, storageOver, type Rows, type Source } from '@fougere/core';

const mapRows = (): Rows => {
  const store = new Map();
  return {
    client: store,
    get: async (key) => store.get(key),
    has: async (key) => store.has(key),
    set: async (key, row) => { store.set(key, row); },
    delete: async (key) => store.delete(key),
    all: async () => [...store.values()],
  };
};

Sources.register('memory', (): Source => ({ storageFactory: storageOver(mapRows), name: 'memory' }));

C'est @fougere/adapter-memory en entier. Le fait qu'all() lise tout est ce qui borne une source sur fichiers : list avec un where ou un orderBy filtre en mémoire, ce qui est juste pour des lignes gardées pour leur durabilité et faux sur un chemin de lecture chaud.

Lire à travers

Les lectures par clé (findByKeys et son dual) enrichissent une page : « je tiens ces lignes, donne-moi les liées ». Elles ne peuvent pas en sélectionner une. Filtrer, trier, paginer ou compter de l'autre côté s'effondre en lisant ce côté entier — et « mes prêts, livre le plus récent d'abord » traverse.

C'est ce que @fougere/adapter-duckdb ferme. Une Frond déclare ce qu'elle lit :

// fronds/library/frond.config.ts
export default defineFrond({
  reads: ['Loan', 'Book'],
});

Le déclarer est ce qui rend Reads injectable, et cela borne aussi ce qui est attaché : une source qui ne tient aucune de ces entités n'est jamais ouverte, donc ses tables n'existent pas dans la connexion. reads: peut nommer n'importe quelle entité de l'application, pas seulement celles de la Frond.

export default class LibraryHandler {
  constructor(private reads: Reads) {}

  async loansByLanguage() {
    return this.reads.read(LoansByLanguage)`
      select b.language, count(*) as loans
      from ${Loan} l join ${Book} b on b.id = l.book_id
      group by b.language
      order by b.language`;
  }
}

Deux choses sont tenues par les types plutôt que demandées :

  • La forme nomme la réponse. Le tag n'est atteignable qu'à travers une forme, donc il n'y a pas de requête sans sortie déclarée. Un select * brut renverrait les champs que boundary.out: 'closed' promet de ne jamais laisser sortir ; la projection est la clôture. Une colonne que la forme ne nomme pas ne sort jamais, et une forme qui déclare un champ que la requête ne répond pas échoue par son nom plutôt que de rendre des null.
  • Seule une entité peut être interpolée. ${Book} devient "archive"."books" — l'alias et la table depuis une seule déclaration. Nommer une entité hors de reads: est refusé, parce que sa table n'est réellement pas dans cette connexion.

Chaque attache est en READ_ONLY, et c'est le moteur qui l'impose. Cette porte lit à travers tout le stockage de l'application : elle est strictement plus atteignable que storage.client — qui garde au moins la portée de son entité — et une écriture par là ne rencontrerait aucun juge.

Ce que ce n'est pas

Pas un constructeur de requêtes. Un builder multi-sources serait un Calcite en TypeScript, et il promettrait une composabilité que les sources n'ont pas. La requête reste du SQL. Ce que Fougere apporte, ce sont les trois dérivations qu'une requête écrite à la main dupliquerait : où vit chaque entité, comment s'appellent sa table et ses colonnes, et la forme de la réponse.

Pas le chemin de lecture ordinaire. Mesuré avant d'écrire quoi que ce soit : à la taille d'une page, DuckDB est ~100× plus lent que deux lectures indexées — environ 7 ms de plancher par requête. À utiliser là où une question traverse vraiment, jamais pour aller chercher une page.

Rien n'est copié. Attacher Postgres pousse le filtre chez lui — Filters: lang='fr' l'atteint sur 100 000 lignes — donc une vraie base est interrogée là où elle est. Pour une source sans aucune algèbre (une API HTTP, le catalogue d'un partenaire), la réponse est Mirror.

Il n'y a aucune liaison de paramètre. Une valeur va dans le texte de requête que vous écrivez : ne construisez donc pas ce texte à partir d'une entrée utilisateur. Filtrez le résultat, ou gardez la partie variable hors de la chaîne.

Limites

  • SQL Server ne peut pas être attaché — il n'existe pas d'extension sqlserver pour DuckDB. Les moteurs attachables sont sqlite, postgres et mysql.
  • Une source doit dire path ou attach : on indique à DuckDB quoi ouvrir, et une base en mémoire à lui ne tiendrait aucune de vos lignes.
  • Un reads: qu'aucun boot ne peut servir est signalé plutôt qu'ignoré.
Construit avec Fougere — ce site tourne sur le framework qu'il documente.