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 :
storageFactory | bâ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 queboundary.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 desnull. - 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 dereads: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.
Limites
- SQL Server ne peut pas être attaché — il n'existe pas d'extension
sqlserverpour DuckDB. Les moteurs attachables sontsqlite,postgresetmysql. - Une source doit dire
pathouattach: 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é.