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

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 fields boundary.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 outside reads: 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.

There is no parameter binding. A value goes into the query text you write, so do not build that text from user input. Filter the result, or keep the varying part out of the string.

Limits

  • SQL Server cannot be attached — no sqlserver extension exists for DuckDB. The attachable types are sqlite, pg and mysql, read off the entry as it is: a path for the first, a url for the other two. Files, memory and D1 cannot be attached either.
  • A reads: no boot can serve is reported rather than ignored.
Built with Fougere and Nuxt, hosted on GitHub Pages.