crabster

Langage CDL

Crabster Domain Language

{% raw %} CDL est la façon de décrire un domaine à Crabster. Un seul fichier .cdl contient tout : les enregistrements à persister, les énumérations qu'ils utilisent, les références entre eux, et les réglages du service qui les sert.

Statut : v0, implémenté. Tout ce qui est documenté ici s'analyse, se génère, et est exercé en CI via examples/shop.cdl. Ce que CDL ne supporte pas est refusé avec un message qui le dit — le langage n'accepte jamais ce que le générateur ignorerait en silence.

1. Principes de conception🔗

2. Un exemple complet🔗

C'est examples/shop.cdl, généré et exécuté par la suite de tests.

service shop_api {
    database sqlite
    port     9000
}

enum OrderStatus { PENDING, PAID, SHIPPED, CANCELLED }

@filterable
record Product {
    reference:  Text @unique
    label:      Text @length(..200)
    summary:    LongText?
    price:      Decimal
    available:  Bool
    releasedOn: Date?
    sku:        Uuid?
}

record Customer {
    email:      Text @unique @matches("^[^@]+@[^@]+$")
    fullName:   Text @length(2..120)
    loyalty:    Int? @range(0..1000)
    signedUpAt: Timestamp
    newsletter: Bool?
}

record Address {
    line1:    Text @length(..200)
    postcode: Text @length(..16)
    country:  Text
    customer: ref Customer @unique   // une adresse par client
}

@filterable
record Order {
    placedAt: Timestamp
    status:   OrderStatus
    total:    Decimal
    customer: ref Customer           // référence obligatoire
    product:  ref Product?           // référence facultative
}

3. Enregistrements et champs🔗

record Product {
    label: Text
}

Un champ s'écrit nom: Type, éventuellement suivi de ? et d'attributs. Chaque enregistrement reçoit son propre id ; vous ne le déclarez jamais, et un champ portant ce nom est refusé — de même qu'un champ dont la colonne s'appellerait table, dont la génération a besoin pour nommer la table en migration. C'est bien la colonne qui est examinée : table: ref B devient table_id et passe.

Obligatoire par défaut🔗

Un champ doit contenir une valeur sauf s'il se termine par ?. Le cas courant est celui qu'on ne devrait pas avoir à écrire.

label:   Text       // doit être présent
summary: LongText?  // peut être absent, et peut être effacé plus tard

La distinction va jusqu'à l'API. Sur une mise à jour, une clé absente laisse la valeur en place tandis qu'un null explicite l'efface — ce qui n'est possible que pour un champ que le modèle autorise à être vide.

4. Types🔗

Type CDLType RustType SQL (PostgreSQL)
TextStringVARCHAR
LongTextStringTEXT
Inti32INTEGER
Longi64BIGINT
Floatf32REAL
Doublef64DOUBLE PRECISION
DecimalDecimalNUMERIC
BoolboolBOOLEAN
DateDateDATE
TimestampDateTimeUtcTIMESTAMPTZ
UuidUuidUUID
BytesVec<u8>BYTEA

Trois de ces types ne se rendent pas de la même façon partout, et c'est la base qui décide :

Un champ peut aussi nommer une énumération déclarée n'importe où dans le fichier, ou un autre enregistrement via ref.

5. Attributs🔗

Les attributs qualifient ce qui les précède, sur la même ligne. Un attribut de champ écrit sur la ligne du dessous se rattacherait au champ précédent ; il est donc refusé, en nommant le champ qu'il aurait qualifié. Seul l'attribut d'un enregistrement s'écrit au-dessus de sa déclaration — c'est ce qui rend la confusion facile, et c'est pourquoi elle est signalée plutôt que devinée.

record Customer {
    name:  Text
    @unique          // refusé : cela rendrait `name` unique, pas `email`
    email: Text
}
AttributSurEffet
@uniqueun champUnicité en base ; sur un ref, en fait un un-à-un. Deux refus ne valent que si le projet vise MySQL : sur LongText ou Bytes, qu'il ne sait pas indexer sans longueur de préfixe, et sur un Text borné au-delà de 768 caractères, sa clé d'index s'arrêtant à 3072 octets et utf8mb4 comptant quatre octets par caractère. PostgreSQL et SQLite acceptent les deux
@length(2..120)du texteBorne la longueur. Chaque extrémité peut rester ouverte : @length(..200), @length(3..). Un champ Text sans borne haute reçoit 255, la limite que MySQL appliquait déjà et désormais les trois ; une borne basse de 255 ou plus exige donc une borne haute, car relever le défaut pour l'atteindre transformerait « au moins 255 » en « exactement 255 »
@range(0..1000)des nombresBorne la valeur, même forme ouverte
@matches("regex")du texteLa valeur doit correspondre. L'expression est compilée à l'analyse : une expression invalide est refusée ici plutôt que de faire paniquer le serveur livré
@filterableun enregistrementLes listes acceptent des filtres en query string, sur tous les champs sauf le texte long, le binaire et les références — ceux-là n'admettent que specified, s'ils sont facultatifs. Refusé s'il ne reste aucun champ filtrable, ou si l'un d'eux s'appellerait page, size, sort ou order — les paramètres que toute liste lit déjà. Les opérateurs sont détaillés dans le guide
@versionedun enregistrementVerrouillage optimiste. L'enregistrement gagne une colonne version ; ses lectures répondent un ETag, et toute écriture doit envoyer If-Match avec la version qu'elle a lue. Une version périmée reçoit 412, une écriture sans condition 428. Sans lui, deux clients qui modifient la même ligne s'écrasent en silence
@auditedun enregistrementL'enregistrement gagne createdAt et updatedAt, écrites par le code au moment de l'écriture — pas par la base, pour que les trois se comportent pareil
@roles(ADMIN, …)un enregistrementSeuls ces rôles atteignent ses endpoints. Voir plus bas
@reads(…) / @writes(…)un enregistrementLa même chose, séparée : qui peut lire, et qui peut créer, modifier ou supprimer

Les colonnes que ces deux attributs ajoutent (version, createdAt, updatedAt) apparaissent dans les réponses et jamais dans ce qu'un client envoie : c'est le compte que le projet tient de ce qu'il a fait. Un champ déclaré sous l'un de ces noms sur un enregistrement qui porte l'attribut est refusé, en nommant l'attribut.

Les ajouter à un enregistrement qui existe déjà est un changement de modèle comme un autre : crabster apply en fait des migrations, et remplit les lignes déjà là — version zéro, et la date du moment pour les deux horodatages.

Un attribut qui ne peut rien signifier sur son champ est refusé plutôt qu'ignoré : @length sur un Int, @range sur du Text, et sur un ref tout sauf @unique — la seule qui y ait un sens, celui d'un un-à-un (§6).

Une longueur négative est refusée, et une longueur au-delà de 65535 aussi : c'est le nombre d'octets qu'une ligne MySQL peut porter, la plus contraignante des trois bases visées. En utf8mb4 un caractère en coûte jusqu'à quatre, si bien que le vrai plafond d'un Text visant MySQL est de 16383 caractères — refusé à la génération, où la base est connue, et non à l'analyse. @length(..0) l'est également, que PostgreSQL rejette en varchar(0) alors que SQLite l'ignore — les tests générés passeraient et seule la production protesterait.

Une borne @range doit tenir dans le type du champ : @range(0..3000000000) sur un Int produit un littéral qu'un i32 ne peut pas contenir. Utilisez Long.

@range sur un Decimal est refusé aussi, en disant pourquoi : la bibliothèque de validation n'a pas de contrôle de bornes pour un décimal de précision arbitraire, donc la contrainte produirait du code qui ne compile pas. Utilisez Double si l'intervalle compte plus que la précision.

Qui peut atteindre un enregistrement🔗

L'autorisation se déclare sur l'enregistrement, elle ne s'écrit pas dans le handler. Ce qui protège une route aujourd'hui est une modification de son corps — claims.require_role("ADMIN") — et une modification de corps est quelque chose qu'une modification ultérieure peut faire disparaître sans que rien ne le remarque.

service api {
    database postgres
    auth     jwt
}

roles { ADMIN, MANAGER, USER }

// Une garde unique sur tout l'enregistrement.
@roles(ADMIN)
record Invoice {
    total: Decimal
}

// Ou séparée, ce qui est la forme courante : beaucoup regardent, peu modifient.
@reads(USER, MANAGER, ADMIN)
@writes(ADMIN)
record Product {
    label: Text
    price: Decimal
}

// Et un enregistrement qui ne nomme personne n'est restreint par rien.
record Note {
    body: Text
}

Plusieurs rôles signifient n'importe lequel suffit. Un appelant portant MANAGER atteint la liste de Product ; un appelant n'en portant aucun reçoit 403, et un appelant sans jeton du tout reçoit 401 — la différence compte, car l'un dit dites qui vous êtes et l'autre vous n'avez pas le droit.

Tout rôle doit être déclaré, et roles { … } est l'endroit. Un rôle qui n'apparaîtrait que sur un enregistrement ferait de @writes(ADMN) une ressource que personne ne peut atteindre plutôt qu'un refus — et une garde qui enferme tout le monde dehors ressemble exactement à une garde voulue. C'est donc refusé, avec la ligne :

line 5, column 1: `Ledger` is guarded by `ADMN`, and no `roles` block declares
it. Declared: ADMIN, MANAGER.

Une garde a besoin de quelque chose à vérifier. Un modèle qui en déclare une dans un projet sans réglage auth est refusé avant que rien ne soit écrit : un rôle se lit dans le jeton d'un appelant, et il n'y a pas de jeton sans module d'authentification. Lequel importe peu : auth jwt et auth oauth2 portent tous deux des rôles, et le même enregistrement produit la même garde sous l'un comme sous l'autre.

Ce qui est généré est un paramètre du handler plutôt qu'une ligne dans son corps — un handler qui ne prend pas sa garde ne compile pas contre la route qui en a besoin. Les tests générés portent un jeton qui a le droit, et un test de plus par enregistrement gardé demande ce qu'il advient d'un appelant qui ne l'a pas.

D'où viennent les rôles eux-mêmes regarde le module d'authentification : auth jwt les conserve sur le compte, auth oauth2 les lit dans une claim du jeton du fournisseur — realm_access.roles pour Keycloak, et configurable, car deux fournisseurs ne s'accordent jamais.

6. Références🔗

Une référence est un champ, pas une déclaration séparée. On l'écrit là où la clé étrangère existe physiquement, et l'inverse est déduit.

record Customer { fullName: Text }

record Order {
    customer: ref Customer      // cette commande pointe vers un client
}

Cette seule ligne donne à la table order une colonne customer_id, une contrainte de clé étrangère, et les relations des deux côtés — un Order appartient à un Customer, un Customer a plusieurs Order. Il n'y a rien à déclarer sur Customer.

ÉcritSignification
customer: ref CustomerPlusieurs commandes par client ; référence obligatoire
customer: ref Customer?Idem, mais une commande peut n'en avoir aucune
customer: ref Customer @uniqueUn-à-un : au plus une ligne par client

Un enregistrement peut se référencer lui-même — c'est ainsi qu'on écrit un arbre :

record Category {
    name:   Text
    parent: ref Category?   // facultatif : la racine n'a pas de parent
}

L'auto-référence doit être facultative : la première ligne insérée n'aurait rien vers quoi pointer.

?expand=parent répond la ligne pointée, sur un seul niveau — la catégorie dépliée porte son propre parentId et non la catégorie qui est derrière. Un arbre se parcourt en redemandant, pas en demandant le tout d'un coup.

Deux références vers le même enregistrement sont acceptées — from: ref Account et to: ref Account — mais l'inverse n'est alors pas généré : SeaORM l'exprime par un Related, qui ne peut exister qu'une fois par cible. Les deux relations directes restent, et ce sont elles dont une requête a besoin.

Plusieurs services dans un fichier🔗

Plus d'un bloc service décrit plus d'une application, et @service dit à laquelle un enregistrement appartient :

service orders  { database postgres  port 8101 }
service billing { database postgres  port 8102 }

@service(orders)
record Order { placedAt: Timestamp }

@service(billing)
record Invoice { total: Decimal }

crabster import-cdl génère alors un projet par service, côte à côte, sous une racine qui porte un docker-compose.yml démarrant l'ensemble d'un coup et un README.md disant qui répond où. Chaque service est un projet Crabster ordinaire — il enregistre un modèle de lui-même, donc crabster record, apply et upgrade y fonctionnent exactement comme sur un projet généré seul. Générer un service parmi d'autres et le générer seul produisent les mêmes fichiers, octet pour octet.

Un service qui dit kind gateway est une passerelle devant les autres plutôt qu'une application :

service edge { kind gateway  port 8100 }

Elle n'a ni base ni enregistrements — une passerelle qui possèderait des enregistrements serait un service — et elle transmet /api/<segment> au service qui possède ce segment, en-tête Authorization compris. Sa table de routage est générée depuis le modèle dans son propre config/default.toml, et elle vous appartient ensuite : une adresse est un fait de déploiement. Un chemin que personne ne revendique est un 404 de la passerelle elle-même, ce qui est là où une erreur de routage doit se voir.

Un service qui dit discovery consul s'enregistre dans un catalogue :

service orders { database sqlite  port 8101  discovery consul }
service edge   { kind gateway     port 8100  discovery consul }

@service(orders)
record Order { placedAt: Timestamp }

Le partage est la conception. Quels chemins un service possède vient du modèle et ne change pas : c'est généré dans la table de la passerelle. Où ce service se trouve change à chaque déploiement : la passerelle le demande à Consul — et retombe sur l'adresse générée quand le catalogue n'a rien de passant, car un catalogue en panne ne doit pas emporter le système avec lui.

L'enregistrement est répété plutôt que fait une fois, donc un Consul qui redémarre retrouve les services en quelques secondes ; le contrôle de santé qu'il exécute est /health/ready, qui atteint tout ce dont le service dépend. Un service qui ne joint pas Consul le signale et continue de servir : un service que rien ne découvre est mauvais, un service qui refuse de démarrer parce qu'un catalogue est tombé est pire.

Un service qui dit config consul lit une partie de ses réglages dans un magasin clé-valeur :

service orders { database sqlite  port 8101  config consul }

record Order { placedAt: Timestamp }

Chaque clé sous config/<service>/ devient un réglage, le séparateur du magasin faisant l'imbrication — config/orders/server/port est server.port. La couche se place sous les fichiers livrés avec le projet et au-dessus de rien : une valeur posée pour la flotte bat un défaut que personne n'a choisi pour ce déploiement, et APP__… dans l'environnement la bat à son tour — une machine peut donc toujours différer sans écrire dans un magasin que tout le monde lit.

C'est lu une fois, au démarrage : un changement atteint un service quand ce service redémarre. Un magasin injoignable n'empêche pas le démarrage — le service se lève sur ses fichiers et son environnement, et le dit.

Un service qui dit session cookie permet à un navigateur de se connecter :

service shop_api {
    database postgres
    auth     oauth2
    session  cookie
}

roles { ADMIN, USER }

@roles(USER, ADMIN)
record Note { body: Text }

auth oauth2 seul fait de ce projet un serveur de ressources : ce qui arrive est un jeton au sujet de quelqu'un, et celui qui l'a envoyé a dû l'obtenir d'abord. Un navigateur ne le peut pas — il n'a nulle part où garder un jeton en sûreté, et un jeton d'accès à portée de JavaScript est à un script tiers de devenir celui de quelqu'un d'autre.

Le navigateur reçoit donc un cookie, et ce service garde le jeton. C'est le motif backend-for-frontend, et c'est ce qui lève l'exclusion des flux à session posée en V1 : la session vit ici, pas dans une interface.

Trois routes viennent avec. /auth/login envoie le navigateur chez le fournisseur en Authorization Code + PKCE ; /auth/callback est là où il revient, et où le code est échangé ; /auth/logout termine la session de ce côté-ci plutôt que de demander au navigateur d'oublier. Le cookie est HttpOnly, SameSite=Lax et Secure hors du profil dev, et il ne porte qu'un identifiant — les jetons restent dans une table ici, si bien qu'un script tiers n'a rien à voler.

Le reste du projet ignore tout cela. Une requête portant un cookie de session se voit poser son en-tête Authorization avant d'atteindre une route : toute garde @roles et tout handler écrit pour un jeton porteur fonctionnent avec un cookie, sans changement. Il n'y a qu'un endroit qui décide qui appelle, et ceci n'en ajoute pas un second.

Ce qui fait refuser un rappel mérite d'être su : celui qui arrive sans le cookie ayant commencé la connexion, ou avec un state qui n'est pas celui que la session de ce cookie détient, est refusé — c'est cet appariement qui empêche qu'un rappel étranger s'achève dans votre navigateur. Et ?next= n'est suivi que s'il désigne un chemin de ce site, une redirection ouverte étant la façon dont une page d'hameçonnage emprunte un domaine le temps d'un clic.

Un service qui dit client typescript génère un client typé à côté de l'API :

service shop_api {
    database sqlite
    client   typescript
}

record Product { label: Text  price: Decimal }

Il atterrit dans clients/typescript/, forme un paquet npm publiable, et ne dépend de rien — fetch est présent dans tous les environnements qu'il vise. Il est généré depuis le même modèle que le serveur : ses types et les réponses de l'API s'accordent par construction et non parce que quelqu'un les tient en phase. Changez le modèle, régénérez, les types suivent.

Il n'est pas produit en lisant le document OpenAPI que ce même modèle engendre. Les deux s'accorderaient de toute façon, et l'un serait une seconde implémentation du premier avec un parse JSON et un générateur de code entre les deux — l'un comme l'autre peuvent se tromper. Ce qui les tient ensemble, c'est que le client généré est compilé, et confronté au serveur qui tourne.

Trois correspondances méritent d'être connues, parce que ce sont celles qu'on devine mal. Un Decimal est une chaîne — passé par un number JavaScript il cesserait d'être à précision arbitraire, donc l'API envoie "42.50" entre guillemets. Un champ facultatif est présent et nul, pas absent : vérifiez la valeur, pas la clé. Et une référence est un nombre, l'identifiant ; l'objet référencé n'apparaît sous son nom que si expand l'a demandé.

client rust en génère un en Rust, sous clients/rust/ — un crate à lui, avec son propre [workspace], si bien qu'il se construit là où il est et qu'un service voisin en dépend par chemin. C'est ce second usage qui en fait autre chose que le client TypeScript dans une autre langue : un système généré par crabster import-cdl est fait de services qui s'appellent, et c'est avec cela que l'un appelle l'autre. C'est aussi ce qui permet à un test d'atteindre cette API par HTTP plutôt que par son propre routeur.

Il nomme directement les crates derrière les types — rust_decimal, chrono, uuid — et seulement ceux que le modèle réclame. Pas le prélude de SeaORM, qui est la façon dont le serveur les écrit : dépendre d'un ORM pour tenir une date ferait porter un pilote de base de données à tout appelant de cette API.

client both génère les deux. Un réglage nomme une décision, et vouloir un client TypeScript pour le navigateur et un client Rust pour le service d'à côté est une décision comme une autre.

Un enregistrement ne peut pas porter le nom d'un type que déclare un client — Listing, Problem, Page, Direction, Client — et il est refusé avec la ligne où il est écrit, plutôt que sous forme de redéfinition dans un fichier que son auteur n'a pas écrit. Uniquement là où le client est demandé : un nom que client-rust réclame est libre dans un projet qui ne l'a pas. Order n'en fait délibérément pas partie, et c'est pourquoi le sens de tri s'appelle Direction : un enregistrement nommé Order est le premier que déclarent la moitié des modèles du monde, et un type de client ne prend pas un nom auquel le modèle a plus droit.

C'est l'ADR-0003 en un réglage : typage de bout en bout et plus de client HTTP écrit à la main, sans que le cœur porte un framework UI à travers ses cycles de rupture.

Un service qui dit telemetry otlp exporte ses spans vers un collecteur :

service edge   { kind gateway  port 8100  telemetry otlp }
service orders { database sqlite  port 8101  telemetry otlp }

@service(orders)
record Order { placedAt: Timestamp }

Tout projet généré journalise déjà un identifiant de requête, et c'est de la corrélation : quelles lignes de journal appartiennent à une même requête. Ceci relève de la causalité : quel appel a causé quel autre, et combien de temps chacun a pris. Les deux voyagent séparément, et c'est voulu. Un identifiant de requête s'adresse à un humain qui lit des journaux et peut être n'importe quoi ; le contexte de trace voyage dans traceparent, a la forme définie par le W3C, et c'est lui qu'un collecteur recoud en une trace.

Un service lit traceparent s'il est là et ouvre une trace à lui s'il ne l'est pas : la bordure du système ne demande donc aucun cas particulier. Une passerelle transmet son propre contexte, pas celui de l'appelant : relayer l'en-tête entrant tel quel ferait du service situé derrière un frère de la passerelle plutôt qu'un enfant, et une trace qui enregistre trois frères a perdu ce pour quoi elle existait.

L'échantillonnage est un réglage, et config/prod.toml l'abaisse — tout exporter est juste pendant qu'on construit un système et faux dès qu'il sert, car le coût se paie sur le chemin de chaque requête. Une décision déjà prise en amont est respectée plutôt que reprise : un service qui rejoue le tirage contredit son voisin sur la même requête, et ce qu'il en reste est une trace trouée là où le service le plus discret se trouvait. Le taux ne décide que des traces qui naissent ici.

Un collecteur injoignable coûte un export raté dans un fil de fond et rien sur le chemin de la requête. Rien ne l'attend, aucun service n'en dépend pour démarrer. Avec crabster import-cdl sur un système, il en est démarré un à côté des services, avec une interface sur localhost:16686.

Deux règles vont avec, refusées en le nommant l'une comme l'autre :

Les migrations sont ordonnées pour créer d'abord la table référencée, puisque la clé étrangère est déclarée dans le CREATE TABLE — seule forme que toutes les bases acceptent. Un cycle de références est refusé, car aucune table ne pourrait alors être créée avant les autres ; passez plutôt par un enregistrement à vous.

Le plusieurs-à-plusieurs n'a pas de syntaxe. Il exige une table de jointure que Crabster ne génère pas, et offrir un mot-clé pour quelque chose qui ne produit rien serait pire que son absence. Modélisez-le par un enregistrement portant deux références.

7. Énumérations🔗

enum OrderStatus { PENDING, PAID, SHIPPED, CANCELLED }

Les valeurs sont utilisées exactement telles qu'écrites, en base comme en JSON. Un modèle qui déclare PENDING obtient une API qui accepte et renvoie "PENDING", pas "Pending".

Stockées en texte, ce qui est portable sur toutes les bases et permet d'ajouter une valeur sans migration de schéma.

8. Le bloc service🔗

service shop_api {
    database sqlite
    port     9000
}
RéglageValeurs
databasepostgres, mysql, sqlite — trois dialectes SQL
mongodb — des documents plutôt que des tables. Pas un quatrième dialecte : voir plus bas
authjwt — ce service possède les comptes : il génère une table, un hachage de mot de passe et /auth/login, et signe ses propres jetons
oauth2 — un fournisseur d'identité possède les comptes : ni table ni login, et un jeton qu'il a émis est validé ici contre les clés qu'il publie
searchmeilisearch — un endpoint de recherche plein texte par enregistrement. Voir plus bas
cacheredis — la lecture d'un enregistrement passe par un cache, et une écriture jette ce qu'elle a changé. Les listes et les lectures dépliées ne sont délibérément pas mises en cache : voir plus bas
messagingnats — chaque enregistrement de ce service publie ses changements, et ce service peut s'abonner à ceux d'un autre. Un binaire, client Rust pur, rien d'ajouté à l'image. Voir plus bas
kafka — la même chose contre Kafka : groupes de consommateurs, rétention, une grappe existante. Le client lie une bibliothèque C, donc l'étage de construction gagne CMake et une chaîne C++
kindgateway — ce service ne persiste rien et relaie /api/<segment> vers celui qui possède ce segment. Un modèle qui en contient un est un système
discoveryconsul — le service s'enregistre, et une passerelle demande au catalogue où sont les autres plutôt que de se fier aux adresses de sa génération
configconsul — une couche de réglages lue au démarrage dans le magasin clé-valeur, sous les fichiers et au-dessus de rien
telemetryotlp — les spans exportées vers un collecteur
clientrust, typescript — un client typé engendré depuis l'API de ce service
uireact, vue, angular — une interface par-dessus ce client, une page par enregistrement. Tire client typescript que vous l'ayez demandé ou non
sessioncookie — une session de navigateur tenue côté serveur, pour un front qui ne doit pas garder de jeton en JavaScript
deploykubernetes — des manifestes Kubernetes dans k8s/ et le même déploiement en chart Helm dans chart/. Rien de spécifique à un fournisseur de cloud : voir k8s/README.md du projet engendré
portLe port d'écoute du serveur généré, de 1 à 65535. 0 est refusé : il demande au système un port libre au hasard, ce qu'un fichier de configuration ne veut jamais dire

Un nom de service est aussi un nom de répertoire. Dans un modèle à plusieurs services, chacun devient un dossier à côté de ceux que la racine écrit pour elle-même — k8s, overlays. Un service portant l'un de ces noms est refusé avant que rien ne soit écrit, en nommant les deux.

Le bloc est facultatif : sans lui, le nom du fichier devient le nom du service, et les valeurs par défaut sont PostgreSQL sur le port 8080. Les options de la ligne de commande priment sur ce que dit le fichier — mais une base inconnue reste refusée, à sa ligne, même si --database allait de toute façon la remplacer : la faute est dans le fichier.

Les réglages autres que database et port allument un module de génération, et lesquels existent n'est pas quelque chose qu'un parseur peut savoir : un blueprint peut en apporter. Un réglage inconnu n'est donc pas refusé ici mais à la génération, par le crate qui connaît les modules installés — avec sa ligne, sa colonne, et la liste de ce à quoi les modules répondent réellement. --with et --without passent outre, pour essayer un module avant d'écrire le réglage.

Retrouver des enregistrements par leur texte🔗

service shop_api {
    database sqlite
    search   meilisearch
}

record Product {
    reference: Text @unique
    label:     Text @length(2..200)
    summary:   LongText?
}

Chaque enregistrement gagne GET /api/<chemin>/search?q=…, qui rend la même page qu'une liste. Un q vide correspond à tout — ce qu'une boîte de recherche vide devrait montrer — et size est borné à 100 comme celui d'une liste.

Il ignore où les enregistrements sont rangés. Ce qui est indexé est le document que l'API renvoie, et ce qu'une recherche rend est ce même document : cela fonctionne donc sur des tables comme sur des documents sans une ligne de différence, et un enregistrement dans l'index a la forme qu'un appelant connaît déjà.

L'index est écrit à la volée. Une création ou une modification y met l'enregistrement, une suppression l'en retire, sur la requête qui l'a causé. Il n'y a aucune tâche à programmer.

Deux choses méritent d'être sues avant de s'y fier.

L'indexation est au mieux : un échec est journalisé et l'écriture réussit quand même. Refuser de stocker un enregistrement parce qu'il n'a pas pu être indexé transformerait une panne de recherche en panne d'écriture, et ce sont les enregistrements qui comptent. La recherche, elle, ne l'est pas — une recherche qui n'atteint pas l'index répond 503 plutôt qu'une page vide, car une page vide se lit « rien ne correspond ».

Et Meilisearch indexe comme une tâche à lui : une recherche faite dans le même souffle qu'une écriture peut être en avance sur elle, d'environ un dixième de seconde. Attendre cette tâche rendrait la phrase exacte et mettrait la latence de l'index sur le chemin de chaque écriture, ce que ce dispositif est fait pour éviter.

Le docker-compose.yml généré en démarre un pour développer, et APP__SEARCH__ADDRESS et APP__SEARCH__KEY désignent celui de la production.

Prévenir les autres services🔗

service orders {
    database  sqlite
    messaging nats       // ou `kafka`
}

Chaque enregistrement de ce service publie ses changements — créé, modifié, supprimé — sur <service>.<table>.<événement>, en portant le document que l'API a renvoyé. src/messaging/subjects.rs nomme chacun d'eux en constante : un publieur ne peut donc pas écorcher un sujet, et un lecteur a la liste.

C'est le canal qui manquait à un système. Une référence ne traverse pas une frontière de service, donc Invoice porte l'identifiant d'une commande comme un champ ordinaire et les deux services « se mettent d'accord sur ce qu'il signifie » — et jusqu'ici rien ne portait cet accord. Une passerelle relaie vers l'intérieur la requête d'un appelant ; elle ne permet pas à orders de dire à billing que quelque chose est arrivé.

Publier est au mieux-effort. Un courtier qui a cessé de répondre ne doit pas faire échouer une écriture déjà réussie : la requête rend son 201 dans les deux cas. Deux conséquences, écrites dans le src/messaging.rs engendré : NATS cœur ne garde rien, donc un abonné qui n'était pas connecté ne reçoit pas ce qu'il a manqué ; et une publication faite pendant que le courtier est injoignable est perdue si le processus s'arrête avant la reconnexion. Pour des événements qui doivent y survivre, activez JetStream.

Le courtier n'est délibérément pas une sonde de disponibilité. Un service dont le courtier a disparu répond encore correctement à tout, et le signaler ferait sortir du répartiteur une instance saine à cause d'un canal annexe.

S'abonner est générique. Un service est engendré depuis un modèle restreint à ses propres enregistrements, pour qu'un service engendré dans un système soit octet pour octet le même projet que ce service engendré seul. Il n'a donc pas de type pour l'Invoice d'un autre, et lui en donner un ferait dépendre son contenu de ses voisins. Déclarez la forme attendue — le document OpenAPI du publieur la contient — et Messaging::subscribe y désérialise.

Lire deux fois le même enregistrement🔗

service shop_api {
    database sqlite
    cache    redis
}

GET /api/<enregistrement>/{id} regarde d'abord dans Redis et y garde ce qu'il a dû aller chercher ; une modification ou une suppression jette l'entrée. C'est tout, et la frontière est précisément l'intérêt.

Une liste n'est pas mise en cache. Elle varie selon la page, la taille, le tri, l'ordre et chaque filtre que le modèle déclare : ses clés sont sans borne — et une écriture sur n'importe quelle ligne peut changer n'importe quelle page de n'importe quel filtre, donc la seule invalidation correcte est de tout jeter. Un cache vidé à chaque écriture est vide quand on le lit.

Une lecture dépliée non plus. ?expand=customer enchâsse un autre enregistrement, et rien ici ne saurait jeter cette copie quand le client change. Une valeur périmée de votre propre ligne est bornée par la durée de vie ; une copie périmée de celle d'un autre, rangée sous votre clé, ne l'est pas.

Il échoue sans gêner. Une lecture qui rate retombe sur la base, une écriture qui rate est journalisée, et la requête réussit dans les deux cas. Et après un échec il cesse de demander pendant retry_after_millis — mesuré, parce que sans cela chaque lecture attendait deux secondes un Redis absent, c'est-à-dire une panne de cache transformée en panne de latence.

Ce que ça coûte. Un rejet qui échoue laisse une entrée périmée, et rien ne réessaie. ttl_seconds est ce qui y met fin, et c'est pourquoi c'est un nombre dans config/default.toml plutôt qu'une constante dans le code : c'est la réponse à « à quel point une lecture peut-elle être périmée », et seul un déploiement peut la donner.

Des documents plutôt que des tables🔗

database mongodb est la seule valeur qui ne nomme pas un dialecte SQL, et ce qu'elle change n'est pas le SQL qu'on écrit :

service shop_api {
    database mongodb
}

enum OrderStatus { PENDING, PAID, SHIPPED }

@filterable
@versioned
@audited
record Product {
    reference: Text @unique
    label:     Text @length(2..200)
    price:     Decimal
    status:    OrderStatus
}

L'API est la même API. Chaque endpoint, chaque paramètre de requête, chaque refus et chaque document de problème. Un identifiant y est un nombre lui aussi, et c'est une décision et non un hasard : celui de MongoDB est un ObjectId, donc une chaîne, et le laisser passer obligerait chaque lecteur de cette API — les clients typés, l'interface, le document OpenAPI, les routes d'une passerelle — à demander où un enregistrement est rangé avant de savoir à quoi ressemble un identifiant. Une collection counters en distribue par une mise à jour atomique, et cet aller-retour est le coût entier.

@unique tient, sous forme d'index construit au démarrage plutôt que d'un CREATE TABLE qu'une collection n'a pas. @versioned tient, la version étant dans le filtre de la mise à jour : la vérification et l'écriture sont une seule opération. @filterable tient, ses opérateurs traduits en ceux de BSON.

Ce qui change est ce qu'un magasin de documents n'a pas. Il n'y a pas de migrations : une collection n'a pas de schéma à faire évoluer, donc rien n'est généré pour le faire et rien n'est à tenir en phase. Et une référence est refusée, en la nommant et avec sa ligne :

line 9, column 5: `Order.customer` points at `Customer`, and this model is
stored as documents. A reference becomes a foreign key, declared inside a
`CREATE TABLE` and checked by the database on every write — a document store
has neither.

Ce refus est tout le dessin. Un champ qui ressemble à une référence, qui se relit comme une référence et que rien ne fait respecter n'échoue pas au moment où on l'écrit : il échoue bien plus tard, quand les données sont déjà fausses. Tenez l'identifiant comme un champ ordinaire, et laissez le code qui écrit les deux les tenir en accord — ce que mongodb décide pour vous de toute façon.

Deux types stockés ne sont pas ceux que le fil transporte, et les deux auraient échoué en silence. Un Decimal est un Decimal128, si bien que ?sort=price place 9,50 avant 10,50 et non après. Et Bytes est une chaîne d'octets plutôt qu'un tableau d'une entrée par octet.

Une base par projet, et pas deux. Il n'existe pas de réglage séparé pour le développement et pour la production. Le schéma généré n'est pas le même d'une base à l'autre — un Timestamp est un DATETIME sur MySQL et un timestamptz ailleurs, un Decimal ne fait pas l'aller-retour sur SQLite — donc développer sur l'une et déployer sur l'autre, ce serait faire passer ses tests contre un schéma que la production n'a jamais. Pour développer sans démarrer de serveur, c'est docker-compose.yml, généré à côté du projet et déjà accordé avec .env.example.

Écrire deux fois la même chose est refusé plutôt que silencieusement résolu : un second bloc service, un réglage répété, un attribut posé deux fois sur un champ. Dans chaque cas le second écrasait le premier sans rien dire.

9. Ce que CDL n'a pas, et pourquoi🔗

10. Diagnostics🔗

Chaque erreur nomme une ligne, et parle le langage plutôt que la grammaire :

line 2, column 3: `Strng` is not a known type for field `Product.label` —
expected one of Text, LongText, Int, … an enum declared in this file, or `ref`
followed by a record
line 3, column 15: `@uniqe` is not an attribute a field accepts —
expected one of unique, length, range, matches

Un modèle qui s'analyse est aussi un modèle qui peut être généré : les noms qui ne pourraient pas devenir des identifiants Rust, les références vers des enregistrements inexistants et les cycles sont tous détectés avant que quoi que ce soit ne soit écrit.

Les collisions de noms générés🔗

Deux noms différents dans le fichier peuvent converger une fois convertis, et le second écraserait alors le premier. Ces cas sont refusés en nommant le nom généré, pas celui qui a été écrit :

line 2: records `BlogPost` and `blog_post` share a module name —
generation would emit `blog_post` twice, and the second would overwrite the first

Sont vérifiés de cette façon :

Trois vérifications ont lieu à la génération et non ici, parce qu'elles dépendent de la base visée — choisie par le bloc service ou par --database, que le parseur ne lit ni l'un ni l'autre. Deux portent sur @unique en MySQL et sont décrites au §5 ; la troisième est celle-ci :

Une énumération ne peut pas porter un nom qu'un module généré occupe déjà : il s'y retrouverait à la fois déclaré et importé. Deux modules sont concernés, et le message dit lequel :

Les deux listes sont courtes et exactes parce que les templates importent par leur nom plutôt que par un glob. Un glob y mettrait tout le prélude de SeaORM — et l'y remettrait à chaque version de SeaORM.

Une énumération portant le nom d'un type intégré — enum Text — est refusée elle aussi : elle serait générée, et un champ la nommant obtiendrait le type intégré, si bien que rien ne pourrait jamais l'atteindre.

La grammaire formelle vit dans crates/crabster-cdl/src/cdl.pest. En cas de désaccord entre elle et ce document, c'est la grammaire qui fait foi. {% endraw %}