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 |
client | what 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.
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.
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.