Storage

Storage<Post> is the set of gestures Post's rows answer. It is a port: fixed, with no flavour of domain, implemented by whichever storage the app resolved.

You do not ask for it by name. A handler, a presenter and a collector reach storage through a repository, which forwards every gesture below and is registered whether or not anyone wrote the file:

export default class PostHandler {
  constructor(private posts: PostRepository) {}

  async recent() { return this.posts.list({ orderBy: 'publishedAt', order: 'desc' }); }
}

Naming the port in one of those three is refused at boot. One way in, and one word for it — so an entity that later belongs to an aggregate moves no call site. The exception is a holder: a class a prefab built over its own entity — a Mirror — may name that entity's port, because holding the storage is what it is for.

A Crud(Post) handler is handed its repository automatically. A query worth a name belongs on that repository rather than spelled at the call site.

The gestures

list(options?)a page, with where, orderBy, limit, offset, count
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)its dual: the rows pointing at each key, as a Map of lists
create(input)insert, and hand back the complete row
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
update(id, input) / delete(id)by key
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 a list of ids could not be passed to it: the only way to read N rows was list() then filter in memory. That is invisible on a local SQLite file and it is the whole table the day the rows are not local.

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 and not positions. A miss is the absence of a key, a repeated key is one entry, and a page zips against it by get(row.authorId). A list could not promise that zip — dropping a miss shifts every later position — so each caller rebuilt the index the implementation had just discarded.

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 throws on the second run, so re-reading a source meant deleting first. 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 acts on none of them, and re-reading a page for a symmetry nobody uses would double the work. It is what Mirror writes through.

Every write through the port is judged

A handler writes freely because it is the author, but the port is still a way out: a value the entity forbids is refused here as it is at the façade. 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 on purpose — narrowing it is the caller saying out loud which implementation they are standing on.

Every judge 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. The scope stays honest — productStorage.client reaches the products, not the whole database — but the judge does not run.

For "debit A and credit B, or neither", reach for Together instead: it is the same port, judged the same way, with a block for its unit instead of a statement.

Next: Writes that stand or fall as one.

Built with Fougere — this site runs on the framework it documents.