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.registergives the type its name and itsopen, called once per entry ofsources:;sideEffectsin the package'spackage.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:
storageFactory | build 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 |
client | what 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.
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.
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.