Sources
An app usually has one database, and declares nothing. When it has several — a legacy
archive, a partner's replica, a warehouse — sources: says which entities live where, and
reads: says which Fronds may query across them.
Where rows live
sources names the places that are not the default one. An entity it does not name
stays in db, so an app with one database behaves exactly as before:
// fougere.config.ts
export default defineFougere({
db: { path: '.data/app.db' },
sources: {
archive: { path: '/mnt/legacy/catalog.db', entities: ['Book'] },
},
});
Book now reads and writes in archive; everything else stays in db. The
storage a handler receives is unchanged — placement is a fact about the
app, not about the code that uses it. A source may also be an engine the caller built, so a
pool Fougere did not open is still a place rows can live.
A derivation makes no table of its own unless it says .anchor(). That
is the entity's own word, never a key of this file: a frond stays mountable without its host
knowing that one of its schemas holds rows.
What realizes a source
source: names the adapter, and what sits below it belongs to that adapter — the shape
adapters: already has on an entity. dialect is SQL's own property and stays there,
because only @fougere/adapter-sql knows what it is worth.
export default defineFougere({
db: { path: '.data/app.db' }, // `source: 'sql'` by convention
sources: {
archive: { source: 'file', path: './rows', entities: ['Snapshot'] },
cache: { source: 'memory', entities: ['Draft'] },
},
});
An adapter answers a name by registering at import, so nothing central lists them and a name
nothing answers is refused saying what this process does answer. Three ship today: sql
(SQLite from a name; another engine is built in code and handed in), file (one JSON per row,
a directory per entity) and memory (a Map, which is also the fallback for an app with no
db).
A source states four gestures and only the first is required:
storageFactory | build the storage of an entity that lives here |
migrate? | bring the shape of what lives here up to date |
transacted? | run one unit of work |
close? | release |
The absence answers too. A source that hands out no transaction makes a frame compensate instead of transacting, and the boot says which of the two it built:
RateCard+Ledger|RateMirrorTogether — compensated: rateCard in 'archive', ledger in 'db' — no isolation
Account+LedgerTogether — transaction, source 'db'
Two frames, one app, two guarantees — and the handlers are the same either way.
Writing one
An adapter supplies Rows, four gestures over a keyed collection, and storageOver derives
the thirteen of the storage port from them. Pages, criteria and
lifecycle stamps are not written twice:
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' }));
That is @fougere/adapter-memory in full. all() reading everything is what bounds a
file-backed source: list with a where or an orderBy filters in memory, which is right
for rows held for their durability and wrong on a hot read path.
Reading across them
By-key reads (findByKeys and its
dual) enrich a page: "I hold these rows, give me the related ones". They cannot
select one. Filtering, sorting, paginating or counting on the other side collapses into
reading that side whole — and "my loans, newest book first" crosses.
That is what @fougere/adapter-duckdb closes. A Frond declares what it reads:
// fronds/library/frond.config.ts
export default defineFrond({
reads: ['Loan', 'Book'],
});
Declaring it is what makes Reads injectable, and it also bounds what gets attached:
a source holding none of these is never opened, so its tables do not exist in the
connection. reads: may name any entity of the app, not only the Frond's own.
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`;
}
}
Two things are held by the types rather than asked for:
- The shape names the answer. The tag is only reachable through a shape, so there is
no query without a declared output. A raw
select *would return the fieldsboundary.out: 'closed'promises never leave; the projection is the fence. A column the shape does not name never comes out, and a shape declaring a field the query does not answer fails by name rather than yielding nulls. - Only an entity may be interpolated.
${Book}becomes"archive"."books"— the alias and the table from one declaration. Naming an entity outsidereads:is refused, because its table genuinely is not in this connection.
Every attach is READ_ONLY, and it is the engine that enforces it. This door reads across
the app's whole storage, so it is strictly more reachable than storage.client — which at
least keeps the scope of its entity — and a write through it would meet no judge at all.
What it is not
Not a query builder. A cross-source builder would be a Calcite in TypeScript, and it would promise a composability the sources have not got. The query stays SQL. What Fougere contributes is the three derivations a hand-written one would duplicate: where each entity lives, what its table and columns are called, and the shape of the answer.
Not the ordinary read path. Measured before any of it was written: at page size DuckDB is ~100× slower than two indexed reads — roughly 7 ms of floor per query. Use it where a question genuinely crosses, never to fetch a page.
Nothing is copied. Attaching Postgres pushes the filter down — Filters: lang='fr' reaches
it over 100 000 rows — so a real database is queried where it is. For a source with no
algebra at all (an HTTP API, a partner's catalog), the answer is
Mirror instead.
Limits
- SQL Server cannot be attached — no
sqlserverextension exists for DuckDB. The attachable engines aresqlite,postgresandmysql. - A source must say
pathorattach; DuckDB is told what to open, and an in-memory database of its own would hold none of your rows. - A
reads:no boot can serve is reported rather than ignored.