Several databases
Most apps have one database and declare nothing. When there are several — a legacy database beside the main one, files for an archive — each entity says where its rows live, and a frond can still read across them.
One table, a view per frond
Two fronds may both declare an Author in one source. Each declares the fields it cares about, and
the table is their union:
// fronds/blog/entities/Author.ts what blog knows of it
class Author extends entity({ id: primary(), name: text(), pen: text() }) {}
// fronds/shop/entities/Author.ts what shop knows of it
class Author extends entity({ id: primary(), name: text(), vat: text() }) {}
// table authors id, name, pen, vat — derived, nothing to write
Each writes and reads its own columns, and never sees the other's. A column one view leaves out is
optional in the table, since what is required is each frond's to judge on its own writes. A field both
declare must be declared the same way — one column has one shape — or the boot refuses, naming it and
both fronds (table-contradicted). In two sources, they are two tables and nothing is shared.
Reading across them
Reads by key (findByKeys) can
complete a page — "I hold these rows, give me the related ones" — but cannot choose 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. Install it with DuckDB, its peer:
npm install @fougere/adapter-duckdb @duckdb/node-api
pnpm add @fougere/adapter-duckdb @duckdb/node-api
bun add @fougere/adapter-duckdb @duckdb/node-api
yarn add @fougere/adapter-duckdb @duckdb/node-api
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 builder across sources would promise that any query composes over any engine, which the engines cannot keep. 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. At page size DuckDB is about 100× slower than two indexed reads — roughly 7 ms per query, however small. 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.