crabster

CDL Language

Crabster Domain Language

{% raw %} CDL is how you describe a domain to Crabster. One .cdl file holds the whole thing: the records to persist, the enumerations they use, the references between them, and the settings of the service that serves them.

Status: v0, implemented. Everything documented here parses, generates, and is exercised in CI through examples/shop.cdl. Anything CDL does not support is refused with a message saying so — the language never accepts what the generator would quietly ignore.

1. Design goals🔗

2. A complete example🔗

This is examples/shop.cdl, generated and run by the test suite.

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   // one address per customer
}

@filterable
record Order {
    placedAt: Timestamp
    status:   OrderStatus
    total:    Decimal
    customer: ref Customer           // mandatory reference
    product:  ref Product?           // optional reference
}

3. Records and fields🔗

record Product {
    label: Text
}

A field is written name: Type, optionally followed by ? and by attributes. Every record gets an id of its own; you never declare it, and a field claiming that name is refused — as is a field whose column would be table, which generation needs to name the table in a migration. It is the column that is checked: table: ref B becomes table_id and is fine.

Mandatory by default🔗

A field must hold a value unless it ends in ?. The common case is the one you should not have to write.

label:   Text       // must be present
summary: LongText?  // may be absent, and may be cleared later

The distinction reaches the API. On an update, an absent key leaves the value alone while an explicit null clears it — which is only possible for a field the model allows to be empty.

4. Types🔗

CDL typeRust typeSQL type (PostgreSQL)
TextStringVARCHAR
LongTextStringTEXT
Inti32INTEGER
Longi64BIGINT
Floatf32REAL
Doublef64DOUBLE PRECISION
DecimalDecimalNUMERIC
BoolboolBOOLEAN
DateDateDATE
TimestampDateTimeUtcTIMESTAMPTZ
UuidUuidUUID
BytesVec<u8>BYTEA

Three of these do not render the same way everywhere, and the database decides:

A field may also name an enumeration declared anywhere in the file, or another record through ref.

5. Attributes🔗

Attributes qualify what precedes them, on the same line. A field attribute written on the line below would attach to the field above it, so it is refused, naming the field it would have qualified. Only a record's attribute is written above its declaration — which is what makes the mistake easy to make, and why it is reported rather than guessed at.

record Customer {
    name:  Text
    @unique          // refused: this would make `name` unique, not `email`
    email: Text
}
AttributeOnEffect
@uniquea fieldUniqueness in the database; on a ref, makes it one-to-one. Two refusals apply only when the project targets MySQL: on LongText or Bytes, which it cannot index without a prefix length, and on a Text bounded above 768 characters, since its index key stops at 3072 bytes and utf8mb4 counts four per character. PostgreSQL and SQLite accept both
@length(2..120)textBounds the length. Either end may be left open: @length(..200), @length(3..). A Text field with no upper bound gets 255, which is the column's limit on MySQL and now on all three; a minimum of 255 or more needs a maximum too, since raising the default to meet it would turn "at least 255" into "exactly 255"
@range(0..1000)numbersBounds the value, same open-ended form
@matches("regex")textThe value must match. The expression is compiled at parse time, so an invalid one is refused here rather than panicking in the delivered server
@filterablea recordList endpoints accept query-string filters, on every field except long text, binary and references — those admit only specified, and only when optional. Refused if no field is filterable, or if one of them would be named page, size, sort or order — the parameters every list already reads. The operators are spelled out in the guide
@versioneda recordOptimistic locking. The record gains a version column; reads answer an ETag, and every write must send If-Match with the version it read. A stale version gets 412, a write with no condition 428. Without it, two clients changing the same row silently overwrite one another
@auditeda recordThe record gains createdAt and updatedAt, written by the code at write time — not by the database, so all three behave the same
@roles(ADMIN, …)a recordOnly these roles may reach any of its endpoints. See below
@reads(…) / @writes(…)a recordThe same, split: who may GET, and who may create, change or delete

The columns these two add (version, createdAt, updatedAt) appear in responses and never in what a client sends: they are the project's own account of what it did. A field declared under one of those names on a record carrying the attribute is refused, naming the attribute.

Adding them to a record that already exists is a model change like any other: crabster apply turns them into migrations, and fills in the rows already there — version zero, and the time it runs for the two timestamps.

An attribute that cannot mean anything on its field is refused rather than dropped: @length on an Int, @range on Text, and on a ref anything but @unique — the one that means something there, namely one-to-one (§6).

A negative length is refused, and so is one beyond 65535: that is how many bytes a MySQL row holds, the tightest of the three databases targeted. In utf8mb4 a character costs up to four of them, so the real ceiling for a Text aimed at MySQL is 16383 characters — refused at generation, where the database is known, rather than at parse time. So is @length(..0), which PostgreSQL rejects as varchar(0) while SQLite silently ignores it — the generated tests would pass and only production would object.

A @range bound must fit the field's own type: @range(0..3000000000) on an Int generates a literal an i32 cannot hold. Use Long.

@range on a Decimal is refused too, and says why: the validation crate has no range check for an arbitrary-precision decimal, so the constraint would generate code that does not compile. Use Double when the range matters more than the precision.

Who may reach a record🔗

Authorization is declared on the record, not written into the handler. What protects a route today is an edit to its body — claims.require_role("ADMIN") — and an edit to a body is something a later edit can drop without anything noticing.

service api {
    database postgres
    auth     jwt
}

roles { ADMIN, MANAGER, USER }

// One guard over the whole record.
@roles(ADMIN)
record Invoice {
    total: Decimal
}

// Or split, which is the common shape: many may look, few may change.
@reads(USER, MANAGER, ADMIN)
@writes(ADMIN)
record Product {
    label: Text
    price: Decimal
}

// And a record that names nobody is narrowed by nothing.
record Note {
    body: Text
}

Several roles mean any one of them will do. A caller carrying MANAGER reaches Product's list; a caller carrying none of them gets 403, and a caller carrying no token at all gets 401 — the difference matters, because one says say who you are and the other says you may not.

Every role has to be declared, and roles { … } is where. A role that appeared only on a record would make @writes(ADMN) a resource nobody can reach rather than a refusal — and a guard that locks everybody out looks exactly like one that was meant to. So it is refused, with the line:

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

A guard needs something to check. A model that declares one in a project with no auth setting is refused before anything is written, because a role is read from a caller's token and there is no token without an authentication module. Which module does not matter: auth jwt and auth oauth2 both carry roles, and the same record generates the same guard under either.

What gets generated is a parameter on the handler rather than a line inside it — a handler that does not take its guard does not compile against the route that needs it. The generated tests carry a token that may, and one more test per guarded record asks what happens to a caller who may not.

Where the roles themselves come from is the authentication module's business: auth jwt stores them on the account, auth oauth2 reads them from a claim in the provider's token — realm_access.roles for Keycloak, and configurable, because no two providers agree.

6. References🔗

A reference is a field, not a separate declaration. You write it where the foreign key actually lives, and the inverse is implied.

record Customer { fullName: Text }

record Order {
    customer: ref Customer      // this order points at one customer
}

That single line gives the order table a customer_id column, a foreign key constraint, and the relations on both sides — an Order belongs to a Customer, a Customer has many Orders. There is nothing to declare on Customer.

WrittenMeaning
customer: ref CustomerMany orders per customer; the reference is mandatory
customer: ref Customer?The same, but an order may have none
customer: ref Customer @uniqueOne-to-one: at most one row per customer

A record may reference itself — that is how a tree is written:

record Category {
    name:   Text
    parent: ref Category?   // optional: the root has no parent
}

A self-reference must be optional: the first row inserted would have nothing to point at.

?expand=parent answers the row it points at, one level deep — the expanded category carries its own parentId and not the category behind it. A tree is walked by asking again, not by asking for the whole of it at once.

Two references to the same record are accepted — from: ref Account and to: ref Account — but the inverse is then not generated: SeaORM declares it through Related, which may exist only once per target. Both forward relations remain, and they are what a query needs.

Several services in one file🔗

More than one service block describes more than one application, and @service says which one a record belongs to:

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 then generates one project per service, side by side, under a root that holds a docker-compose.yml starting all of them at once and a README.md naming what answers where. Each service is an ordinary Crabster project — it records a model of itself, so crabster record, apply and upgrade work on it exactly as they do on a project generated alone. Generating a service among others and generating it alone produce the same files, byte for byte.

A service that says kind gateway is a proxy in front of the others rather than an application:

service edge { kind gateway  port 8100 }

It has no database and no records — a gateway that owned records would be a service — and it forwards /api/<segment> to whichever service owns that segment, Authorization header and all. Its routing table is generated from the model into its own config/default.toml, and is yours from there: an address is a fact about a deployment. A path no service claims is a 404 from the gateway itself, which is where a routing mistake belongs.

A service that says discovery consul registers itself with a catalogue:

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

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

The split is the design. Which paths a service owns comes from the model and does not change, so it is generated into the gateway's table. Where that service is changes every deployment, so the gateway asks Consul instead — and falls back to the generated address when the catalogue has nothing passing, because a catalogue that is down must not take the system with it.

Registration is repeated rather than done once, so a Consul that restarts has the services back within seconds; the health check it runs is /health/ready, which reaches everything the service depends on. A service that cannot reach Consul logs it and keeps serving: one nothing can discover is bad, one that refuses to start because a catalogue is down is worse.

A service that says config consul reads part of its settings from a key-value store:

service orders { database sqlite  port 8101  config consul }

record Order { placedAt: Timestamp }

Every key under config/<service>/ becomes a setting, the store's own separator doing the nesting — config/orders/server/port is server.port. The layer sits under the files the project ships with and over nothing: a value set for the fleet beats a default nobody chose for this deployment, and APP__… in the environment beats it in turn, so one machine can always be made to differ without writing to a store everybody reads.

It is read once, at startup — a change reaches a service when that service restarts. A store that cannot be reached does not stop the service: it comes up on the files and the environment, and says so.

A service that says session cookie lets a browser sign in:

service shop_api {
    database postgres
    auth     oauth2
    session  cookie
}

roles { ADMIN, USER }

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

auth oauth2 alone makes this project a resource server: what arrives is a token about somebody, and whoever sent it had to get it first. A browser cannot — it has nowhere safe to keep a token, and an access token within JavaScript's reach is one cross-site script away from somebody else's.

So the browser gets a cookie and this service keeps the token. That is the backend-for-frontend pattern, and it is what lifts V1's exclusion of session-based flows: the session lives here, not in a UI.

Three routes come with it. /auth/login sends the browser to the provider with Authorization Code and PKCE; /auth/callback is where it comes back, and where the code is exchanged; /auth/logout ends the session on this side rather than asking the browser to forget. The cookie is HttpOnly, SameSite=Lax and Secure outside the dev profile, and it carries an identifier — the tokens stay in a table here, so a cross-site script has nothing to steal.

The rest of the project does not know any of this happened. A request carrying a session cookie has its Authorization header put on before it reaches a route, so every @roles guard and every handler written against a bearer token works with a cookie unchanged. There is one place that decides who is calling, and this does not add a second.

What a callback is refused for is worth knowing: one arriving without the cookie that started the login, or with a state that is not the one that cookie's session holds, is refused — that pairing is what stops somebody else's callback being completed in your browser. And ?next= is followed only when it is a path on this site, since an open redirect is how a phishing page borrows a domain for the length of one click.

A service that says client typescript generates a typed client beside the API:

service shop_api {
    database sqlite
    client   typescript
}

record Product { label: Text  price: Decimal }

It lands in clients/typescript/, is a publishable npm package, and depends on nothing — fetch is in every runtime it targets. It is generated from the same model the server is, so its types and the API's answers agree by construction rather than by anyone keeping them in step: change the model, regenerate, and the types move with it.

Not generated by reading the OpenAPI document this same model produces. The two would agree anyway, and one of them would be a second implementation of the first with a JSON parse and a code generator in between — both of which can be wrong. What holds them together instead is that the generated client is compiled, and put against the running server.

Three mappings are worth knowing because they are the ones a reader would guess wrong. A Decimal is a string — put through a JavaScript number it would stop being arbitrary-precision, so the API sends "42.50" quoted. An optional field is present and null, not absent: check the value, not the key. And a reference is a number, the identifier; the referenced object appears under its own name only when expand asked for it.

client rust generates one in Rust instead, under clients/rust/ — its own crate, with its own [workspace], so it builds where it stands and a sibling service depends on it by path. That second use is why it is not merely the TypeScript client in another language: a system generated by crabster import-cdl is services calling services, and this is what one of them calls another with. It is also what lets a test reach this API over HTTP rather than through its own router.

It names the crates behind the types — rust_decimal, chrono, uuid — directly, and only those the model reaches for. Not SeaORM's prelude, which is how the server spells them: depending on an ORM to hold a date would make every caller of this API carry a database driver.

client both generates both. One setting names one decision, and wanting a TypeScript client for the browser and a Rust one for the service beside it is a decision like any other.

A record may not be named after a type a client declares — Listing, Problem, Page, Direction, Client — and is refused with the line it is written on rather than as a redefinition in a file its author never wrote. Only where the client is asked for: a name client-rust claims is free in a project that does not have it. Order is deliberately not among them, which is why the sort direction is called Direction: a record called Order is the first record half the models in the world declare, and a client type may not take a name the model is more entitled to.

This is ADR-0003 in one setting: end-to-end typing and no hand-written HTTP client, without the core carrying a UI framework across its breaking-change cycles.

A service that says telemetry otlp exports spans to a collector:

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

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

Every generated project already logs a request id, and that is correlation — which log lines belong to one request. This is causality: which call caused which, and how long each took. The two are carried separately on purpose. A request id is for a human reading logs and can be anything a caller sends; trace context travels in traceparent, is W3C-shaped, and is what a collector stitches a trace from.

A service reads traceparent if it is there and starts a trace of its own if it is not, so the edge of a system needs no special case. A gateway sends its own context on rather than the caller's: forwarding the incoming header unchanged would make the service behind it a sibling of the gateway rather than a child, and a trace that records three siblings has lost the thing it was for.

Sampling is a number in the settings, and config/prod.toml lowers it — exporting every span is right while a system is being built and wrong once it serves, because the cost is on the path of every request. A decision an upstream already made is honoured rather than taken again: a service that re-rolls the dice disagrees with its neighbour about the same request, and what that leaves is a trace with holes where the quieter service was. The ratio decides only the traces that start here.

A collector that cannot be reached costs a failed export in a background thread and nothing on the request path. Nothing waits for it, and no service depends on it to start. With crabster import-cdl on a system, one is started beside the services with a UI on localhost:16686.

Two rules come with it, and both are refused by name:

Migrations are ordered so a referenced table is created first, since the foreign key is declared inside CREATE TABLE — the only form every database accepts. A cycle of references is refused, because no table could then be created before the others; route it through a record of your own instead.

Many-to-many has no syntax. It needs a join table Crabster does not generate, and offering a keyword for something that produces nothing would be worse than its absence. Model it as a record of its own holding two references.

7. Enumerations🔗

enum OrderStatus { PENDING, PAID, SHIPPED, CANCELLED }

Values are used exactly as written, both in the database and in JSON. A model declaring PENDING gets an API that accepts and returns "PENDING", not "Pending".

Stored as text, which is portable across every database and lets a value be added without a schema migration.

8. The service block🔗

service shop_api {
    database sqlite
    port     9000
}
SettingValues
databasepostgres, mysql, sqlite — three SQL dialects
mongodb — documents rather than tables. Not a fourth dialect: see below
authjwt — this service owns the accounts: it generates a table, a password hash and /auth/login, and signs its own tokens
oauth2 — an identity provider owns the accounts: no table and no login, and a token it issued is validated here against the provider's published keys
searchmeilisearch — a full-text search endpoint per record. See below
cacheredis — the single-record read goes through a cache, and a write drops what it changed. Lists and expanded reads are deliberately not cached: see below
messagingnats — every record of this service publishes its changes, and this service can subscribe to another's. One binary, pure-Rust client, nothing added to the image. See below
kafka — the same, against Kafka: consumer groups, retention, an existing cluster. The client binds a C library, so the build stage gains CMake and a C++ toolchain
kindgateway — this service persists nothing and forwards /api/<segment> to whoever owns that segment. A model with one is a system
discoveryconsul — the service registers itself, and a gateway asks the catalogue where the others are rather than trusting the addresses it was generated with
configconsul — a layer of settings read from the key-value store at startup, under the files and over nothing
telemetryotlp — spans exported to a collector
clientrust, typescript — a typed client generated from this service's own API
uireact, vue, angular — a front end over that client, one page per record. Pulls client typescript in whether or not you asked for it
sessioncookie — a browser's session held server-side, for a front end that must not keep a token in JavaScript
deploykubernetes — Kubernetes manifests in k8s/, and the same deployment as a Helm chart in chart/. Nothing specific to a cloud provider: see the generated project's k8s/README.md
portThe port the generated server listens on, 1–65535. 0 is refused: it asks the OS for any free port, which is never what a configuration file means

A service name is a directory name too. In a model with several services, each becomes a folder beside the ones the root writes for itself — k8s, overlays. A service carrying one of those names is refused before anything is written, naming both.

The block is optional: without it the file name becomes the service name, and the defaults are PostgreSQL on port 8080. Command-line flags override whatever the file says — but a database Crabster cannot target is still refused, at its line, even when --database was going to replace it anyway: the mistake is in the file.

Settings other than database and port turn a generation module on, and which ones exist is not something a parser can know: a blueprint may bring one. An unknown setting is therefore not refused here but at generation, by the crate that knows the installed modules — with its line, its column, and the list of what those modules actually answer to. --with and --without override both, for trying a module before writing the setting.

Finding records by their text🔗

service shop_api {
    database sqlite
    search   meilisearch
}

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

Every record gains GET /api/<path>/search?q=…, answering the same page a list answers. An empty q matches everything, which is what an empty search box should show, and size is clamped to 100 as a list's is.

It does not know where the records are kept. What is indexed is the document the API answers with, and what a search returns is that same document — so this works over tables and over documents without a line of difference, and a record in the index has the shape a caller already knows.

The index is written through. A create or a change puts the record in, a delete takes it out, on the request that caused it. There is no job to schedule.

Two things about that are worth knowing before you rely on them.

Indexing is best-effort: a failure is logged and the write still succeeds. Refusing to store a record because it could not be indexed would turn a search outage into a write outage, and the records are the thing that matters. Searching is not best-effort — a search that cannot reach the index answers 503 rather than an empty page, because an empty page reads as nothing matches.

And Meilisearch indexes as a task of its own, so a search made in the same breath as a write may be a moment behind it — around a tenth of a second. Waiting for that task would make the sentence exact and put the index's latency on the path of every write, which is the coupling this is built to avoid.

The generated docker-compose.yml starts one to develop against, and APP__SEARCH__ADDRESS and APP__SEARCH__KEY point at whichever one production has.

Telling the other services🔗

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

Every record of this service publishes its changes — created, updated, deleted — on <service>.<table>.<event>, carrying the document the API answered with. src/messaging/subjects.rs names every one of them as a constant, so a publisher cannot misspell a subject and a reader has the list.

This is the channel a system was missing. A reference does not cross a service boundary, so Invoice holds an order's identifier as a plain field and the two services "agree on what it means" — and until now nothing carried that agreement. A gateway forwards a caller's request inward; it does not let orders tell billing that something happened.

Publishing is best-effort. A broker that has stopped answering must not make a write fail that already succeeded, so the request returns its 201 either way. Two things follow, and both are stated in the generated src/messaging.rs: core NATS keeps nothing, so a subscriber that was not connected does not receive what it missed; and a publish made while the broker is unreachable is lost if the process ends before it reconnects. For events that must outlive that, turn on JetStream.

The broker is deliberately not a readiness check. A service whose broker is gone still answers every request correctly, and reporting it as not ready would have an orchestrator pull a healthy instance out of rotation for a side channel.

Subscribing is generic. A service is generated from a model narrowed to its own records, so that a service generated inside a system is byte for byte the same project as that service generated alone. It therefore has no type for another service's Invoice, and giving it one would make its contents depend on its siblings. Declare the shape you expect — the publisher's OpenAPI document has it — and Messaging::subscribe deserialises into it.

Reading a record twice🔗

service shop_api {
    database sqlite
    cache    redis
}

GET /api/<record>/{id} looks in Redis first and keeps what it had to fetch; a change or a deletion drops the entry. That is the whole of it, and the boundary is the point.

A list is not cached. It varies by page, size, sort, order and every filter the model declares, so its keys are unbounded — and a write to any row can change any page of any filter, so the only correct invalidation is to drop everything. A cache cleared on every write is empty when it is read.

An expanded read is not cached. ?expand=customer embeds another record, and nothing here would know to drop that copy when the customer changes. A stale value of your own row is bounded by the time-to-live; a stale copy of somebody else's, held under your key, is not.

It fails out of the way. A read that fails falls through to the database, a write that fails is logged, and the request succeeds either way. And after a failure it stops asking for retry_after_millis — measured, because without that every read waited two seconds for a Redis that was not there, which is a cache outage turned into a latency outage.

What that costs. A drop that fails leaves a stale entry, and nothing retries it. ttl_seconds is what ends it, which is why it is a number in config/default.toml rather than a constant in the code: it is the answer to "how stale can a read get", and only a deployment can answer it.

Documents rather than tables🔗

database mongodb is the one value that does not name a SQL dialect, and what it changes is not the SQL written against it:

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
}

The API is the same API. Every endpoint, every query parameter, every refusal and every problem document. An identifier is a number here too, and that is a decision rather than an accident: MongoDB's own is an ObjectId, which is a string, and letting that through would mean every reader of this API — the typed clients, the UI, the OpenAPI document, a gateway's routes — has to ask where a record is kept before knowing what an identifier looks like. A counters collection hands out numbers with one atomic update, and that round trip is the whole cost.

@unique holds, as an index built at startup rather than a CREATE TABLE a collection does not have. @versioned holds, with the version in the filter of the update so that the check and the write are one operation. @filterable holds, with the operators mapped onto BSON's.

What changes is what a document store does not have. There are no migrations: a collection has no schema to alter, so nothing is generated to alter it and nothing is kept in step. And a reference is refused, by name and with its line:

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.

That refusal is the whole design. A field that looks like a reference, reads back like one and is enforced by nothing does not fail when it is written; it fails much later, once the data is already wrong. Hold the identifier as a plain field instead, and let the code that writes both keep them in step — which is the decision mongodb makes for you either way.

Two stored types are not the ones the wire carries, and both would otherwise fail quietly. A Decimal is a Decimal128, so ?sort=price orders 9.50 before 10.50 rather than after it. And Bytes is a byte string rather than an array with one entry per byte.

One database per project, not two. There is no separate setting for development and for production. The generated schema is not the same from one database to the next — a Timestamp is a DATETIME on MySQL and a timestamptz elsewhere, a Decimal does not round-trip on SQLite — so developing on one and deploying on another would mean passing your tests against a schema production never has. For developing without standing a server up yourself, there is docker-compose.yml, generated beside the project and already agreeing with .env.example.

Writing the same thing twice is refused rather than quietly resolved: a second service block, a repeated setting, an attribute written twice on one field. In each case the second used to overwrite the first without saying so.

9. What CDL does not have, and why🔗

10. Diagnostics🔗

Every error names a line, and speaks the language rather than the grammar:

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

A model that parses is also a model that can be generated: names that could not become Rust identifiers, references to records that do not exist, and cycles are all caught before anything is written.

Generated-name collisions🔗

Two names that differ in the file can converge once converted, and the second would then overwrite the first. Those are refused, naming the generated name rather than the written one:

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

Checked this way:

Three checks run at generation rather than here, because they depend on the database the project targets — chosen by the service block or by --database, neither of which the parser reads. Two of them are about @unique on MySQL and are described in §5; the third is this one:

An enumeration may not take a name a generated module already has, because the name would then be both declared and imported in one module. Two modules are involved, and the error says which:

Both lists are short and exact because the templates import by name rather than through a glob. A glob would put SeaORM's whole prelude here — and put it here again on every SeaORM release.

An enumeration named after a built-in type — enum Text — is refused as well: it would be generated, and a field naming it would get the built-in type instead, so nothing could ever reach it.

The formal grammar lives in crates/crabster-cdl/src/cdl.pest. Where it and this document disagree, the grammar is the truth. {% endraw %}