Le port Storage
Storage<Post> est le jeu de gestes auquel les lignes de Post répondent. C'est un
port : fixe, sans parfum de domaine, implémenté par le stockage que l'application a
résolu.
Vous ne le demandez pas par son nom. Un handler, un presenter et un collector atteignent le stockage par un repository, qui transmet tous les gestes ci-dessous et est enregistré que le fichier ait été écrit ou non :
export default class PostHandler {
constructor(private posts: PostRepository) {}
async recent() { return this.posts.list({ orderBy: 'publishedAt', order: 'desc' }); }
}
Nommer le port dans l'un de ces trois est refusé au boot. Un seul chemin, et un seul mot
pour le dire — de sorte qu'une entité qui appartiendra plus tard à un
agrégat ne fait bouger aucun appel. L'exception est un
détenteur : une classe qu'un prefab a bâtie sur son entité — un
Mirror — peut nommer le port de cette entité,
parce que tenir le stockage est sa raison d'être.
Un handler Crud(Post) reçoit son repository
automatiquement. Une requête qui mérite un nom vit sur ce repository, pas épelée sur le
site d'appel.
Les gestes
list(options?) | une page, avec where, orderBy, limit, offset, count |
findById(id) | une ligne, ou undefined |
findBy(criteria) / findAllBy(criteria) | une ligne, ou toutes, par égalité |
findByKeys(ids) | un ensemble de lignes par clé, en Map |
findAllByKeys(field, keys) | son dual : les lignes qui pointent vers chaque clé, en Map de listes |
create(input) | insère, et rend la ligne complète |
upsert(input) | écrit la ligne, ou fait ressembler l'existante à celle-ci |
upsertAll(inputs) | une page entière en une instruction, répond combien de lignes ont été écrites |
update(id, input) / delete(id) | par clé |
output(view) | un storage restreint aux champs d'une vue |
client | ce que le port enveloppe — voir l'avertissement plus bas |
Lire un ensemble, pas une ligne à la fois
findAllBy compare avec =, donc on ne pouvait pas lui passer une liste d'identifiants :
le seul moyen de lire N lignes était list() puis un filtre en mémoire. C'est invisible sur
un fichier SQLite local, et c'est la table entière le jour où les lignes ne sont plus là.
const authors = await authorStorage.findByKeys(posts.map((p) => p.authorId));
return posts.map((post) => ({ ...post, author: authors.get(post.authorId) }));
Il répond une Map, parce que l'appelant tient des clés et non des positions. Une absence
est l'absence d'une clé, une clé répétée est une entrée, et une page se rapproche par
get(row.authorId). Une liste ne pouvait pas promettre ce rapprochement — laisser tomber
une absence décale toutes les positions suivantes — donc chaque appelant reconstruisait
l'index que l'implémentation venait de jeter.
findAllByKeys est l'autre direction de la même relation :
// tous les commentaires de tous les posts de la page, en une requête
const byPost = await commentStorage.findAllByKeys('postId', posts.map((p) => p.id));
Une clé sans ligne est simplement absente. Ensemble, les deux couvrent les deux côtés d'une relation, chacun en une requête — c'est ce qui permet à un presenter de recevoir la page et de n'émettre qu'une lecture pour elle.
Promise.all(rows.map(...)) dans un champ calculé
émet toujours une lecture par ligne, et rien ne le dit.Écrire une page
create lève à la deuxième passe, donc relire une source imposait de supprimer d'abord.
upsert écrit la ligne ou fait ressembler l'existante à celle-ci, en une instruction :
await storage.upsert({ id: 'isbn-9782070423200', title: 'La Peste' });
La clé et les marques de création survivent à l'écrasement — une ligne garde le moment où elle est apparue. Un moteur sans clause d'upsert refuse par son nom plutôt que d'en émuler une avec une lecture devant, ce qui promettrait une atomicité qu'il n'a pas.
upsertAll prend une page et répond combien de lignes ont été écrites, pas les lignes :
un import n'agit sur aucune, et relire une page pour une symétrie que personne n'utilise
doublerait le travail. C'est ce à travers quoi
Mirror
écrit.
Toute écriture par le port est jugée
Un handler écrit librement parce qu'il en est l'auteur, mais le port reste une sortie : une
valeur que l'entité interdit est refusée ici comme elle l'est à la façade. Les règles de
cycle de vie sont réalisées à l'entrée — created() est estampillé, update: 'forbidden'
est appliqué — donc un handler ne les remplit jamais à la main.
client — et ce qu'il coûte
client est l'instance sous-jacente : celle de Kysely pour l'adapter SQL, autre chose
ailleurs. Il est typé unknown volontairement — le rétrécir, c'est l'appelant qui dit tout
haut sur quelle implémentation il se tient.
client
n'en rencontre aucun : une valeur que l'entité refuse atterrit dans la table sans un mot. La
portée reste honnête — productStorage.client atteint les produits, pas toute la base — mais le
juge ne tourne pas.Pour « débiter A et créditer B, ou ni l'un ni l'autre », prendre
Together : le même port, jugé de la même façon, avec un bloc
pour unité au lieu d'une instruction.