Write a source adapter

An adapter hands Fougere a Store — five gestures over a keyed collection (get, has, set, delete, all) and its client — and storageOver derives every gesture of the storage from them: pages, criteria and lifecycle stamps are written once, in core. @fougere/adapter-memory is this, and little more:

import { Format, type Accepted } from '@fougere/schema';
import { Sources, storageOver, type Store, type Values, type Source } from '@fougere/core';

function mapStore(): Store {
  const map = new Map<string, Values>();

  return {
    client: map,
    get: async (key) => map.get(key),
    has: async (key) => map.has(key),
    set: async (key, values) => { map.set(key, values); },
    delete: async (key) => map.delete(key),
    all: async () => [...map.values()],
  };
}

function createMemorySource(): Source {
  const stores = new Map<string, Store>();
  const storageFactory = storageOver((_entity, name) => {
    const store = stores.get(name) ?? mapStore();
    stores.set(name, store);

    return store;
  });

  return { storageFactory, name: 'memory' };
}

const MEMORY = Format.of('source/memory').key('type', Format.tokens(['memory'])).needs('type').closed();

declare module '@fougere/core' {
  interface FougereSources {
    memory: Accepted<typeof MEMORY>;
  }
}

Sources.register('memory', { format: MEMORY, open: () => createMemorySource() });

Three things make the type exist:

  • the format says what an entry of this type may hold. The editor reads it as a type, through FougereSources, and the boot judges each entry by it — a misspelled key is refused in both;
  • Sources.register gives the type its name and its open, called once per entry of sources:;
  • sideEffects in the package's package.json — ["./dist/index.js"] — keeps the import. An app imports the adapter only for its registration, and a bundler drops an import it believes unused. List the entry the app imports and the file that registers.

One Store per entity name, as here: two fronds declaring Author in this source then read the same rows, as they would one SQL table.

all() reads everything, which is what bounds such a source: a list with a where or an orderBy filters in memory. Right for rows kept for their durability, wrong on a hot read path.

What a source states

A source states five things, 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 what lives here up to date — run by fougere migrate --apply, never by a boot
transacted?run one unit of work
close?release

A source with no transacted is not a lesser one: a frame over it compensates instead of transacting, and the boot says which it built:

Together<[RateCard, Ledger], [RateMirror]> — compensated: rateCard in 'archive', ledger in 'main' — no isolation
Together<[Account, Ledger]>                 — transaction, source 'main'

A source may also be an engine you built: any other Kysely dialect — libSQL, PGlite, Neon — is handed in with createKyselySource(dialect, 'sqlite').

The gestures

list(options?)a page, with where, orderBy, order, limit, offset, page, after, count, select
findById(id)one row, or undefined
findBy(criteria) / findAllBy(criteria)one row, or all of them, matching by equality
findByKeys(ids)a set of rows by key, as a Map
findAllByKeys(field, keys)the other way: the rows pointing at each key, as a Map of lists
create(input)insert, and hand back the complete row
createAll(inputs)several rows in one statement, each handed back whole, in the order given; one conflict writes none
upsert(input)write the row, or make the existing one look like this
upsertAll(inputs)a whole page in one statement, answering how many were written
updateByKeys(ids, change)a set of rows changed from what they hold, all held at once, in key order
update(id, input) / delete(id)by key — input may be a function of the row, read and written with no other write in between (how)
output(view)a scoped storage whose reads are restricted to a view's fields
clientwhat the port wraps — see the warning below

Reading a set, not a row at a time

findAllBy compares with =, so it takes no list of ids. Reading N rows by key is findByKeys, in one query — not the whole table filtered afterwards, which goes unnoticed on a local file and costs the whole table the day the rows are far away.

const authors = await authorStorage.findByKeys(posts.map((p) => p.authorId));
return posts.map((post) => ({ ...post, author: authors.get(post.authorId) }));

It answers a Map, because the caller holds keys, not positions: a missing row is a missing key, a repeated key is one entry, and a page matches each row with get(row.authorId). A list would shift every later position the moment one row is missing.

findAllByKeys is the other direction of the same relation:

// every comment of every post on this page, in one query
const byPost = await commentStorage.findAllByKeys('postId', posts.map((p) => p.id));

A key with no rows is simply absent. Together the two cover both sides of a relation, each in one query — which is what lets a presenter receive the page and issue one read for it.

The shape forces the query, not the body. Promise.all(rows.map(...)) inside a computed field still issues one read per row, and nothing says so.

Writing a page

create refuses a row whose key exists. upsert writes the row, or makes the existing one look like this, in one statement:

await storage.upsert({ id: 'isbn-9782070423200', title: 'La Peste' });

The key and the creation stamps survive an overwrite — a row keeps the moment it appeared. An engine with no upsert clause refuses by name rather than emulating one with a read in front, which would promise an atomicity it has not got.

upsertAll takes a page and answers how many rows were written, not the rows: an import does nothing with them. It is what Mirror writes through.

Every write through the port is validated

A handler writes without the client's checks, since it is the author, but not around the entity: a value the entity forbids is refused here as it is at the door. Lifecycle rules are realized on the way in — created() is stamped, update: 'forbidden' is enforced — so a handler never fills those by hand.

client — and what it costs

client is the underlying instance: the Kysely one for the SQL adapter, something else elsewhere. It is typed unknown: narrowing it is you saying which engine you rely on.

Every validator sits on the port's own methods. A statement issued through client meets none of them: a value the entity refuses lands in the table without a word. Nor is it scoped: productStorage.client is the Kysely instance of the whole database, not of the products.

For "debit A and credit B, or neither", use Together: the same checks, with a block as its unit instead of a statement.

Built with Fougere and Nuxt, hosted on GitHub Pages.