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:

storageFactorybuild 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 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 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.

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 engines are sqlite, postgres and mysql.
  • A source must say path or attach; 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.
Built with Fougere — this site runs on the framework it documents.