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.

typeSaysImported from
sqlitepath@fougere/adapter-sql
pg, mysql, mssqlurl@fougere/adapter-sql
d1binding — the Worker's D1 binding@fougere/adapter-sql
filepath — one JSON per row, a directory per entity@fougere/adapter-file
memorynothing — 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:

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