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
clientce 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.

La forme force la requête, pas le corps. 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.

Tous les juges sont posés sur les méthodes du port. Une instruction émise par 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.

Suite : Des écritures qui tiennent ou tombent ensemble.

Construit avec Fougere — ce site tourne sur le framework qu'il documente.