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 every place rows live. The one entry that lists no entities holds every
entity the others do not, so an app with one database writes one line:
// fougere.config.ts
import '@fougere/adapter-sql';
export default defineFougere({
sources: {
main: { type: 'sqlite', path: '.data/app.db' },
archive: { type: 'sqlite', path: '/mnt/legacy/catalog.db', entities: ['Book'] },
},
});
Book now reads and writes in archive; everything else stays in main. Two entries listing
no entities refuse the boot, naming both: nothing would say which one holds the rest. No
sources: at all keeps the rows in memory, and the boot says so. 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.
The type of a source
type: names the engine, and what sits beside it belongs to that engine. Every entry writes
it: an entry with none is refused, naming the types this process answers.
type | Says | Imported from |
|---|---|---|
sqlite | path | @fougere/adapter-sql |
pg, mysql, mssql | url | @fougere/adapter-sql |
d1 | binding — the Worker's D1 binding | @fougere/adapter-sql |
file | path — one JSON per row, a directory per entity | @fougere/adapter-file |
memory | nothing — a Map | @fougere/adapter-memory |
import '@fougere/adapter-sql';
import '@fougere/adapter-file';
export default defineFougere({
sources: {
main: { type: 'sqlite', path: '.data/app.db' },
archive: { type: 'file', path: './rows', entities: ['Snapshot'] },
legacy: { type: 'pg', url: process.env.DATABASE_URL, entities: ['Book'] }, // pnpm add pg
},
});
Nothing is imported for you. The import is what registers a type and what makes it a word
the config may write: with none, the editor refuses every entry, and the boot refuses
Unknown source type … To add one, import the adapter that provides it. Each type declares its entry once,
as a format: the editor reads it as a type, and the boot judges the entry by it — pathh is
refused in both, by name, and { type: 'pg' } without a url too.
@fougere/adapter-sql loads on any runtime — Node, Bun, Deno, a Worker. An engine reads what it
needs when it OPENS: SQLite its native driver and node:fs, a server its driver from the app
(better-sqlite3, pg, mysql2, or tedious and tarn), so an app that opens none installs
nothing native, and a missing one is refused with the command that adds it. On a Worker,
type: 'd1' reads its binding from cloudflare:workers at the first query.
Any other Kysely dialect — libSQL, PGlite, Neon — is built in code and handed in with
createKyselySource(dialect, 'sqlite').
A source states five gestures and only the first is required:
storageFactory | build the storage of an entity that lives here |
pending? | what the entities ask for and this source does not hold yet — read, never written. The boot refuses when it is not empty |
migrate? | bring the shape of what lives here up to date — run by fougere migrate --apply, never by a boot |
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 'main' — no isolation
Account+LedgerTogether — transaction, source 'main'
Two frames, one app, two guarantees — and the handlers are the same either way.
Writing one
An adapter supplies a Store, four gestures over a keyed collection, and storageOver derives
every gesture of the storage port from them. Pages, criteria and
lifecycle stamps are not written twice:
import { Sources, storageOver, type Store, type Source } from '@fougere/core';
const mapRows = (): Store => {
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. The register runs when the package is imported, and an
app imports it for that alone (import '@fougere/adapter-memory'), so the package says so in its
package.json — "sideEffects": ["./dist/index.js"] — or a bundler drops the import as unused and the
type answers nothing in a built app. List the entry the app imports and the file that registers.
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 facade reads across
the app's whole storage, so it reaches as far as storage.client
does, and a write through it would meet no validator 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 types aresqlite,pgandmysql, read off the entry as it is: apathfor the first, aurlfor the other two. Files, memory and D1 cannot be attached either. - A
reads:no boot can serve is reported rather than ignored.