Changelog
The repository holds a single copy of this document: both languages of this site point at the same text.
{% raw %} Format based on Keep a Changelog. Versioning follows SemVer: before 1.0, a minor bump may carry breaking changes, and every one of them is listed here.
The generator and the templates are versioned separately — see
CONTRIBUTING.md. Changes are
tagged [generator], [templates], [language], [examples] or
[docs] accordingly, because a template change can break an existing
generated project without the CLI moving at all.
[Unreleased]🔗
Added🔗
-
[language] [templates]
cache redis.GET /api/<record>/{id}goes through Redis and a write drops what it changed. The boundary is the feature: a list is not cached, because it varies by page, size, sort, order and every filter the model declares, and a write to any row can change any of them — the only correct invalidation would be to drop everything, and a cache cleared on every write is empty when it is read. An expanded read is not cached either, since it embeds a record this module could not know had changed. Measured: one database query on a miss, none on a hit. It also stops asking after a failure — without that, every read waited two seconds for a Redis that was not there, which is a cache outage turned into a latency outage. -
[language] [templates]
messaging kafka, besidemessaging nats. The two generate the same thing — the same subjects from the model, the samepublishon every write, the same genericsubscribe— and differ in what they cost and what they give. NATS: a pure-Rust client, nothing added to the image, a broker up in a second, and a consumer that was away hears nothing it missed. Kafka: consumer groups, retention, an existing cluster — and a client bindinglibrdkafka, so the Dockerfile's build stage gains CMake, a C++ toolchain and Perl. The settings table in the CDL reference lays the two side by side, as does the examples page. -
[language] [templates]
messaging nats— events between services. A model with severalserviceblocks generated several applications that could not speak to each other: a reference does not cross a service boundary, so a record holds another service's identifier as a plain field and the two "agree on what it means" — and nothing carried that agreement. A gateway forwards a caller's request inward; it does not let one service tell another that something happened. Every record of a service that asks for it now publishes created, updated and deleted on<service>.<table>.<event>, carrying the document the API answered with, andMessaging::subscribeconsumes another service's. NATS rather than Kafka: the Rust client is pure Rust, where Kafka's bindslibrdkafkaand would putcmakeinto every generated project's build stage and its shared objects into a distroless runtime that has none. -
[docs] The
serviceblock settings table lists every setting. It documented five of twelve:kind,discovery,config,telemetry,client,uiandsessionall worked and none was written down.
Changed🔗
- [templates]
publishtakes the record's identifier. Kafka orders within a partition and not across them, so anOrderwhose events were spread over partitions could be read updated before it was created; the identifier as partition key puts one record's whole history in one partition. NATS has no partitions and ignores it — one call site in the write handlers rather than one per broker. The subjects moved out ofmessaging-natsinto amessaging-coreboth brokers require, since they come from the model and are identical either way.TEMPLATES_VERSIONis0.22.0for those two. - [templates] The Dockerfile has a
build_depsslot. A module whose dependencies need a toolchain can say so;messaging-kafkais the first andmessaging-natsadds nothing, which is the difference between the two made visible in the image. - [templates] Building the state is
async. A state that owns a connection cannot be built any other way, and the messaging module's field is one. The five call sites await it; a blueprint that callsAppState::newmust too, which is what movesTEMPLATES_VERSIONto0.21.0rather than the new module itself. - [templates] A configuration key nothing declares stops the server.
APP__SERVERR__PORT=9000used to leave the server on the port it already had and say nothing — the same mistake an unknown query-string filter has always been refused for. Settings of your own go in the two new regions ofsrc/config.rs, which regeneration keeps. - [templates]
config/schema.json, beside the files it describes. Derived fromSettingsrather than written next to it, so the two cannot part company, and named by a#:schemaline that VS Code and the JetBrains IDEs both read: keys are completed as they are typed and a misspelt one is underlined before anything runs.
[0.3.0] - 2026-09-17🔗
What a report turned up, and what it took to answer it. Somebody ran
crabster new block, answered the three questions, and got twenty files and a
server — while the README said a monolith comes with a front end and named
three of them. Every entry below follows from that one attempt.
The templates move to 0.20.0, and it is the first time that number has
moved without a core template changing: the three UI blueprints ship inside the
binary now and gained a sign-in screen, so a project generated with ui react
under 0.19.0 has something to receive and crabster upgrade is what gives it
one.
Added🔗
-
[generator]
crabster newcan make what people mean by an application. It gains--auth jwt,--ui react|vue|angularand--record NAME, and the wizard asks for the first two — so one command produces a REST API, a database, accounts with registration and login, a typed client and a front end over it, with a.cdlleft in the project because the model is the thing to grow.The report that prompted it was blunt and correct:
crabster new blockanswered with twenty files and a server. The wizard asked about architecture, database and port and then stopped — not because those were the interesting questions but because they were the only ones it could answer, which is ADR-0006 working exactly as written. Both blockers are gone: the UI blueprints ship in the binary, andnewcan write a model for one application the way it already wrote one for a system.authanduigenerate against records, so they arrive with a record — asked for with a default that can be typed over, never invented in silence, and refused outright where there is no terminal to ask on and no--record, with a message naming the flag. A barecrabster newis untouched: no model file, no front end, no accounts. Verified by generating one, compiling it, starting it, registering an account and creating a record through the API, and building the front end.
Fixed🔗
-
[generator] The three official UI blueprints now ship in the binary, so
ui reactis a setting an installedcrabstercan answer. They could not be reached at all before: they lived underexamples/, which does not enter the published.crate, and the first attempt from an installed binary was told "uiis not a setting any installed module answers to" — while the README promised three interfaces and named them. A promise nothing keeps.They are installed by default, and that is safe for one stated reason: they only ever add a module, claimed by a setting nothing else answers to, so a project that does not write
ui …is byte for byte the project it was before they existed. A blueprint that overrides a built-in module by name — Diesel replacingrecord, GitLab replacingcore— changes what every project is made of, cannot be a default, and stays underexamples/blueprints/behind--blueprint. That rule is held by a test, and so is the other half of ADR-0003: a--blueprintdirectory of your own carrying aui-reactmodule still replaces the shipped one wholesale. The ADR carries a dated amendment saying what did and did not change.Two things fell out of doing it.
cargo testran green against a test that refuses a bundled blueprint overriding a core module, while that exact violation was on disk:build.rswatchedtemplatesand not the newblueprintstree, so the binary still held the tree from the build before — which is the trap that build script was written for, one directory too narrow. Andui sveltenow answers "uiacceptsangular,reactorvue" instead of naming a setting nobody has. -
[examples] The three UI blueprints could not sign in, so a project that actually guarded its records met a front end that could not read them. They were written against
session cookie, where the browser holds a cookie and sends it unasked; underauth jwt— a project signing its own tokens — they built the generated client with no token at all. Generated, installed and built without complaint, then rendered a red "noAuthorization: Bearerheader" and nothing else. Reported from a real attempt rather than found here, which is its own lesson about what the tests were proving.Each of the three now opens on a sign-in screen when
auth jwtis in the model, with registration beside it because a freshly migrated database has no users to sign in as. The token is read on every call, so signing out lands on the next request. Conditional on the module rather than always present: templates could already ask{% endraw %}{{ "{% raw %}" }}{% raw %}{% if "auth-jwt" in modules %}{% endraw %}{{ "{% endraw %}" }}{% raw %}, and a file that renders to nothing is not written — so a project without accounts gets no session code and no screen.Held by a test that operates the form: a headless Chrome driven over the DevTools protocol fills it, submits it, and then requires a row the API holds to be on the page — seeded over the wire by another account, because a front end that signs in and then meets 401 on every call renders the same headings and the same empty table as one that works. The first version of this test passed against exactly that failure, which is why the row is there.
-
[examples] The Angular blueprint's navigation had no labels.
textContent="Product"is an attribute that does nothing in an Angular template — the binding is[textContent], and what the model decides can simply be written as content. Two empty buttons in a page that read correctly, because both record names appear elsewhere on it; the test asserted the words were present and they were. It now asserts they are inside a button.
Added🔗
-
[examples] Two more UI blueprints: Vue and Angular, beside React. The same screen over the same generated client — one page per record, paging, creation, deletion, and a refusal shown from the problem document the API answers with.
ui vueandui angularnext toui react.They are the test of ADR-0003 rather than an addition to it. The decision was that the core carries a typed client and no UI framework, so a second and third framework had to cost a directory under
examples/blueprints/and nothing at all undercrates/. They did: neither blueprint changed a file outside its own tree.Angular is the interesting one, because the shape an Angular reader expects — an
HttpClientservice wrapping every endpoint in anObservable, behind an interceptor chain — would have been a second implementation of a generated client that already returns typed data and already throws a typed refusal. What it uses Angular for instead is the part Angular is better at: standalone components and signals, withstrictTemplatestype-checking every expression in a template against the model's own types.Both are held to what official means here, which is not a word: the heavy tier generates, installs, builds and then renders each one in a real browser engine, requiring the rows the API holds to be in the DOM. That is not ceremony — Angular's bundle built perfectly while the application never started, for want of a Zone.js polyfill in
angular.json, and an empty<app-root>in a page that served fine is exactly what a build cannot see.
[0.2.0] - 2026-09-16🔗
The generator becomes a lifecycle tool. The templates it ships are 0.19.0,
the number a generated project records and the one a later crabster upgrade
crosses — 0.1.0 shipped 0.16.1, so every project generated with it has three
template versions to cross, and crabster upgrade is the thing that crosses
them.
Every phase of the V2 scope is delivered but two.
crabster upgrade carries a project across a templates version, merging what
you edited with what the templates changed and never overwriting in silence;
crabster apply turns a changed model into fresh migrations; crabster introspect writes a .cdl from a database that already exists; and crabster new asks for what it needs instead of being told in flags. A model can name
several service blocks and generate a system — a gateway, service discovery,
a database per service — and deploy it under Compose or Kubernetes. Records can
be kept in MongoDB rather than in tables, searched full text through
Meilisearch, guarded by roles the model declares, and read through a typed
TypeScript or Rust client generated beside them. Tokens can come from an
OAuth2/OIDC provider, and a browser can hold a session against one.
What is not here: cloud deployment beyond a conformant cluster (phase 17), and a blueprint maintained by somebody outside this repository (phase 18), which is waiting on people rather than on code.
Added🔗
-
[templates]
deploy kubernetes, a new value for a newdeploysetting.k8s/holds manifests with every value already filled in — the port the model asked for, the probes the service actually serves, the image its ownDockerfilebuilds — sokubectl apply -f k8s/is the whole instruction: namespace, a development database with its volume, config, secret, deployment, service, ingress and autoscaler.chart/holds the same deployment as a Helm chart, for when a value has to differ between environments. Three probes rather than two, because they answer three questions: a startup probe covers the migrations, liveness must not reach the database, and readiness must. No sub-generator per cloud — what EKS, GKE and AKS need beyond a conformant cluster is an ingress class, a storage class and a way to push an image, and the generatedk8s/README.mdnames those three rather than pretending to generate them. Verified by deploying it on minikube behind ingress-nginx and reading a record back through the Ingress. -
[templates] Templates move to 0.19.0. The overlays below are the reason; it also carries the whole
templates/system/k8s/tree, which arrived under 0.18.0 without the number moving. Two builds stamping one version while rendering different files is the ambiguity the number exists to prevent. -
[templates] A system can be deployed more than once.
k8s/gains a kustomize base andoverlays/a worked example, so a second cluster is a directory saying what differs rather than a copy of the manifests that will drift. Four things differ between clusters and nothing else in a generated system does: the namespace, where the images come from and which, how many of each, and the way in. The overlay sits besidek8s/rather than inside it because kustomize refuses an overlay nested in its own base — it walks upward looking for a root, finds the base containing the overlay, and calls it a cycle. Not one system spread across clusters: that needs a mesh, which one is a decision about your network rather than your model, and generating for one would be the sub-generator per vendor this deployment refuses. -
[templates] A system deploys as a system. A model with several
serviceblocks generates ak8s/above the projects: one namespace for all of them — one, because these services reach each other by name and a name resolves inside a namespace without qualification, so splitting them would turn every address intoservice.namespace.svc.cluster.localand be wrong the first time anybody deployed the system twice. Consul and Jaeger when services ask for them, a database per service that keeps anything, and one way in: the gateway if there is one, otherwise a host per service. The gateway's routing table is generated as an override mounted over its image'sconfig/prod.toml, because a Kubernetes object name may not contain an underscore — a service the model callscatalog_apianswers tocatalog-api, and the table baked into the image would point at nothing, which is a 502 on every path that service owns with nothing to explain it. Verified by deploying two services and a gateway on minikube, writing to both through the Ingress, and reading both back. -
[examples] The showcase lists what this repository ships, and a test holds it to that. A table kept by hand rots both ways: a blueprint added and not listed is one nobody finds, and a row naming a templates version the blueprint does not declare sends a reader to something Crabster refuses to load, with a message about the version rather than about the page that was wrong.
-
[examples]
crabster applyworks with the Diesel blueprint. A model change becomes a SQL migration directory rather than a Rust module, on both backends: PostgreSQL takes the directALTER TABLE, SQLite the table rebuild it needs for anythingALTERcannot express. Verified on a database with rows in it — two rows carried their identifiers through three migrations including a full rebuild, the one that was empty took the--defaultit was given, and the one that was not kept what it had. -
[generator] A migration can be a directory, not only a
*.rsfile.Migrations::recordedmatched a module by its Rust file name, so a project whose migrations aremigrations/<name>/up.sqlrecorded none — and everyapplynumbered from one again. Two migrations sharing a number is a collision for the built-in templates and worse for a blueprint generating SQL, where the order they run in is the order their names sort. -
[generator]
ChangeViewcarries the default as a SQL literal beside the Rust one. They are not the same string: a text value is"blue"in Rust and double quotes in SQL name a column, so a migration carrying the Rust form would run, find no such column, and fail saying nothing about the default. Single-quoted here, with its own quotes doubled. -
[examples] The Diesel blueprint generates PostgreSQL as well as SQLite. What changes is the schema types, the migrations' SQL, the connection the pool hands out and how the tests get a database; the handlers, the DTOs and the endpoints are the same file either way. Neither backend needs a library on the machine, which is what makes it usable rather than merely written:
pq-syswithbundledandopenssl-syswithvendoredcompile libpq and TLS into the binary, so the generatedDockerfileis unchanged and the distroless runtime image — which has neitherlibpqnorlibssl— still starts.Uuidworks on PostgreSQL and stays refused on SQLite; MySQL is refused, because libmysqlclient has no source-bundled crate and the same trick is not available. Verified by running the generated suite against a PostgreSQL the suite starts itself. -
[examples] A third official blueprint: Diesel instead of SeaORM. Crabster picked one ORM, as a generator has to; this is what reversing that costs. The
recordmodule keeps its shape — the same endpoints, status codes and problem documents — and nine files replace the layer underneath, while the OpenAPI document, the validation, the page contract and everythingcoregenerates are inherited untouched. SQLite, because Diesel's other backends link against libpq or libmysqlclient and a generated project cannot assume either is installed — least of all inside its ownrust:slimimage. What it does not implement it refuses:@filterable,@versioned,@audited, references, enumerations,DecimalandUuidproduce acompile_error!naming the feature and the record, because a project that silently ignored@versionedwould answer 200 where it owes a 412. -
[generator]
crabster blueprint newrefuses a directory whose name no crate can carry, before it writes anything: the crate is named after the directory, andmy.blueprint.v2is a perfectly good directory thatcargorefuses as a package. The message names the legal spelling. -
[generator] And it warns when it writes inside another project's workspace. The empty
[workspace]it generates stops that workspace adopting the blueprint, which is enough when the host lists its members by name — and not enough when the host matches them with a glob, where the blueprint becomes a second workspace root and everycargocommand in the host fails. The warning says the remedy; the guides no longer claim the table settles it unconditionally. -
[generator]
crabster blueprint newwrites a publishable manifest, so thecrabster-blueprintkeyword that makes a blueprint findable is what you get by default rather than what a document asks you to remember. With the three lines beside it that are easy to get wrong:include, without which the package publishes with no templates in it; an empty[workspace], without which a blueprint written inside another project is adopted by that project's workspace andcargorefuses to run in either; and a stub target, because a package must have one. The round trip — scaffold, package, unpack elsewhere, generate — is now checked rather than assumed. -
[generator]
crabster blueprint, with three verbs.newwrites a blueprint that generates, because a scaffold that does not run leaves its author unable to tell whose fault a failure is.checksays whether a blueprint's modules load and name things that exist, without generating anything — the same faults surface at generation, but on the machine of whoever used the blueprint rather than for its author.contractprints what there is to write against. -
[generator]
blueprint-contract.txt, and a test that holds the templates to it. The names a blueprint depends on — modules, generated files, slots, protected regions, variables, reserved names — were never declared as a promise anywhere, so renaming one was a one-line edit that passed every test and broke every blueprint that had named it.CONTRIBUTING.mdnow says which change is a patch and which is a minor bump. -
[templates]
auth oauth2, a second value for theauthsetting, and the templates version moves to0.17.0. Whereauth jwtmakes a project own its accounts — a table, a password hash,/auth/login, tokens it signs itself —auth oauth2makes it a resource server: an identity provider owns the accounts, and what arrives is a token about one. So there is no table, no migration and no login here; there is discovery through.well-known/openid-configuration, a cached key set that survives a rotation without a network call per request, and validation ofiss,aud,expandnbfagainst a pinned algorithm. Symmetric algorithms are refused by name: a resource server checks a signature against a public key, and a public key is not a secret. Roles are read from a configurable dotted path, because Keycloak writesrealm_access.roles, Entra ID writesrolesand Auth0 writes a namespaced claim, and no generator can choose between them. One endpoint,GET /auth/me, says what a token means to the service. -
[templates] A Keycloak to develop against, in
docker-compose.yml, withkeycloak/realm.jsonimported on every start: the realm, a client, two roles and a userdemo/demo. Its healthcheck asks for the discovery document this project reads at startup rather than for Keycloak's own health, because Keycloak answers on its port well before the realm is imported and a token asked for in between comes back 404 — which reads like a wrong issuer rather than a provider that has not finished starting.cargo test -- --ignoredin a generated project asks that provider for a token and puts it through the same validation every request goes through. -
[generator] A CI job for authentication, which there was none of.
auth jwtwas covered by the end-to-end tier, whose tests build a database and a signing key in process;auth oauth2cannot work that way, because deciding whether to believe a token somebody else signed needs somebody else. The job generates a project, starts the provider from that project's own Compose file, runs its tests including the ignored one, then asks the running service for/auth/mewith a token fetched the way a client would — and checks the two refusals, no token and a forgedalg: none. -
[templates]
docker-compose.ymlis generated whenever a module has a service to put in it, not only when the database needs one. A SQLite project keeps its data in a file and still wants an identity provider running beside it. -
[templates]
Authenticatedworks in a handler your model generated. It documented that it did and it could not: the extractor resolved against a state no generated router carried.AppStatenow takes fields from a module that needs them, andauth-oauth2hands one out throughFromRef. This is the change that moved every project'ssrc/state.rs, whichever modules it uses. -
[language] More than one
serviceblock describes more than one application, and@service(name)says which one a record belongs to.crabster import-cdlgenerates one project per service, side by side — and each is an ordinary Crabster project, because each records a model of itself rather than of the whole file. Generating a service among others and generating it alone produce the same files byte for byte, which is what letsrecord,applyandupgradework on one without knowing it was ever part of something larger. Two refusals come with it: with several services every record has to say which it belongs to, and a reference may not cross from one to another — a foreign key lives inside aCREATE TABLEand two services are two databases, so there is no key that reaches across and no migration order that spans both. -
[language]
discovery consulon a service registers it with a Consul catalogue and lets a gateway resolve it there. Which paths a service owns comes from the model and is generated; where that service is comes from the catalogue, and the generated address stays as a fallback so a Consul that is down does not take the system with it. Registration is repeated rather than done once — Consul's register is an upsert, so a Consul that restarted has everything back within seconds — and a Consul that cannot be reached is logged and carried on from: a service nothing can discover is bad, one that refuses to start because a catalogue is down is worse. The check Consul runs is/health/ready, the same endpoint a load balancer would use. -
[language]
config consulon a service reads part of its settings from Consul's key-value store. Every key underconfig/<service>/is a setting, the store's separator doing the nesting. 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 the environment beats it in turn, so one machine can always be made to differ without writing to a store everybody reads. Read once at startup, and said so rather than implying a reload that does not happen; a store that cannot be reached does not stop the service, which comes up on what it would have had anyway. -
[templates]
Settings::loadis async, and a module can add a layer to it. A layer that has to be fetched could not be added to a synchronous builder, and where such a layer sits is the whole of what it means. -
[templates] A generated gateway serves one contract for the whole system:
/api-docs/openapi.jsonthere is every service's document merged, with a console over it. Schema names carry the service they came from —orders.Order— always rather than on collision, because two services will sooner or later both describe aProblem, merging those by name makes one silently become the other, and prefixing only on collision would make a name depend on which other services exist. Every$reffollows its name, so the merged document resolves. A service that does not answer is named in the description and inx-crabster-unreachable, because a document missing part of the system with nothing saying so does not look incomplete. -
[generator]
Cargo.tomldeclares a crate once however many modules asked for it. Three modules now needreqwestorserde_jsonand a manifest that declares one twice is one Cargo refuses. The file that knows its own lines are keys is the one that says so, through aone_per_keyfilter — identical contributions to a slot were already made once, and that was not enough as soon as two modules spelled the same dependency differently. -
[templates] A generated gateway limits how much one client may ask for: twenty requests a second and forty at once by default, answered over the limit with a 429 and a
Retry-After, before anything is forwarded. Per client and not global, because one counter for the whole gateway lets a busy caller starve every other one — which is the failure a limit exists to prevent, moved rather than fixed. The client is the socket's peer and notX-Forwarded-For, which a caller writes itself: trusting it without a proxy in front is a limit that limits nobody, so it takes a deliberatetrust_forwarded_for. And the table of clients is bounded by an eviction that is exact rather than approximate — a client whose allowance has refilled completely is indistinguishable from one that was never seen, so forgetting it changes no answer. -
[templates] A handler can know who is calling: the server is started with connection info, so the peer address reaches a request. Nothing that needed it could be written before, and a project that never asks for it never notices.
-
[examples]
examples/system.cdl: three applications in one model — two services and a gateway in front — generated as three projects plus a root that starts all of it. It is also where the rule that separates a system from an application cut into pieces is shown:Invoiceholds an order's id as a plainLong, because a reference does not cross a service boundary, and a test edits it into arefto confirm that is still refused by name. -
[templates] A request can be followed across a whole system. Every service already logged an
x-request-id, and the gateway forwards the one it was given or mints one when the caller sent none — so a single value follows a call from the edge to whichever service answered it, and onegrepover the system's logs is the whole of it. That is correlation and not a trace: it says which lines belong to one request, not how long each hop took. Exporting real spans is not generated, and this id is what would carry the correlation into it. -
[generator] A generated gateway is compiled and put through its own tests. The suite did that for services and not for gateways, so the day the gateway's settings grew two fields, its own test fixture stopped compiling and every check stayed green.
-
[generator] Two modules contributing the same text to one slot contribute it once. The dependencies slot is filled by whichever modules are on, and two that both need
reqwestwrote the key twice — a manifest Cargo refuses. Identical contributions only: two modules asking for different versions of one crate disagree about something, and quietly keeping the first would answer that disagreement by accident. -
[language]
kind gatewayon a service generates a reverse proxy in front of the others instead of an application. It forwards/api/<segment>to whichever service owns that segment — headers included,Authorizationabove all, since the services behind it validate their own tokens — streams bodies rather than buffering them, and answers a path nobody claims with a 404 of its own rather than passing it to whichever service happened to be first. It has no database and no domain model, because a gateway that owned records would be a service. Its routing table is generated intoconfig/default.tomland recorded in.crabster/project.toml, socrabster upgraderegenerates the same one: an address is a fact about a deployment and no.cdlcarries one. -
[templates] A distributed generation writes a root above the services: a
docker-compose.ymlthat builds and starts all of them at once, each on the port the model declared and each with its own database, and aREADME.mdnaming what answers where. The root is a project of its own — one module, no Rust — and it records the whole model, socrabster upgradecarries it across a templates version like anything else. It is generated before the services, because generation refuses to write into a directory that already holds one. -
[generator] A model can be written back out as CDL (
crabster_cdl::to_cdl). It exists so a service can record a model of itself, and what it writes has to parse back to the model it was written from. -
[generator]
crabster newasks. Run it with no arguments on a terminal and it puts three questions — the project name, the database, and the port — and produces the project the equivalent flags produce, byte for byte. It asks only when stdin really is a terminal, and--no-inputturns it off even then, so a pipe, a CI runner and a script are unaffected. Every question has a flag that says the same thing, and giving the flag is what suppresses the question: this is a second way of answering, not a second way of configuring. Line-based and numbered rather than arrow-key driven, which is why it added no dependency.--databaseand--portno longer declare their defaults to clap, because a default supplied by clap cannot be told apart from a value somebody typed. -
[templates]
telemetry otlp, a new service setting, and with it spans a collector can stitch into one trace. Every generated project already logged a request id, which is correlation — which lines belong to one request. This is causality: which call caused which and how long each hop took. The two are carried apart on purpose, the request id for a human reading logs and W3Ctraceparentfor the collector. A service adopts the trace it arrives in or starts one, so the edge needs no special case, and a gateway hands on its own context rather than the caller's — relaying the incoming header would make the service behind it a sibling instead of a child, and a trace of three siblings has lost what it was for. Sampling is a setting thatconfig/prod.tomllowers, and a decision taken upstream is honoured rather than retaken: a service that re-rolls disagrees with its neighbour about one request and leaves a trace with holes in it. A collector that cannot be reached costs a failed export on a background thread and nothing on the request path; no service waits for it or depends on it to start.crabster import-cdlon a system starts one beside the services, with a UI onlocalhost:16686. -
[generator]
optional_contributionsin a module manifest. A module may now contribute to a file that only sometimes exists — telemetry has a line forsrc/gateway.rs, and most projects have no gateway — where before every declared contribution had to land or generation failed. The check still holds for every destination not named, which is what keeps a misspelt path an error rather than a silent omission. -
[generator]
crabster newasks whether you want one application or several, and builds the system if you say several. It asks for a gateway, the services and their databases and ports, whether they register with a catalogue and whether they export spans — then writes the model and generates from it. Every question has a flag, as ADR-0006 requires:--architecture,--service NAME[:DB[:PORT]],--gateway NAME[:PORT],--discoveryand--telemetry. The design is that the questions produce a.cdland the.cdlgoes through the pathcrabster import-cdlalready took, so there is no second generator to keep in step — held to it by test: the system answered for, the system the equivalent flags make, and the system imported from the written.cdlare the same files with the same bytes. The model is left in the root, because it is the thing to grow. -
[language] A model with no record is valid when it declares a service. That is a project before its first record — what every service of a new system is — and refusing it meant a system could only ever be created complete. A file that declares neither is still refused, and says so in those words.
-
[generator] A service with no records is generated without the record machinery. It used to arrive with a listing type, a pagination helper, an expansion parser and a dozen warnings about code nothing calls; now it is what
crabster newproduces, andcrabster recordbrings the rest with the first record. What decides is whether the model holds a record, not whether a file was read. -
[generator]
--discoveryand--telemetryon a monolith are refused by name instead of accepted and ignored. Both are a service block's to carry, andcrabster newmakes no model for one application to put them in — so the refusal names the flag and says which way out there is. -
[generator] Nothing invents a gateway. Asked for a system by flag with no
--gatewayand no terminal to ask on, the command used to put one in front under a name nobody chose; now silence means no gateway, and pressing enter at the question still means yes. -
[generator]
crabster upgradeat the root of a system upgrades the system. It assembles the model again from what each service now holds — records, enumerations, the block saying which port and database it answers on — regenerates the root from that, rebuilds the gateway's routing table, and crosses every service to the current templates, each through the same merge a single project's upgrade does. No flag says "this is a system": a root records a model declaring several services and a project never does, so the directory already says which it is. This is what carries a record added inside a service through to the gateway, and nothing else did: what a gateway routes is what the model says a service owns, and a system is created before its services own anything. Two services declaring one enumeration name with different values are refused by name. -
[generator] One implementation of a gateway's routing table, shared by generation and upgrade. Two would have been how a gateway comes to route what nothing owns.
-
[generator] Four tests that start a real PostgreSQL and a real MySQL and run the generated migrations against them. Both databases were previously compiled against and asserted about as text — that
.env.examplesayspostgres://, that the Compose file names an image — and never executed, so whether a server would accept the SQL this generator writes was nobody's test. The only database ever run was SQLite, which is the one that accepts the most: it does not resolve a foreign key's parent atCREATE TABLEtime, so it tolerates a migration order the other two refuse. Two of the tests cover that order with a model written child-first; the other two take the shop example through its whole life — migrate, write rows, change the model, migrate again — and check the rows written before the change are still there afterwards. The images are the ones a generateddocker-compose.ymlstarts, so what is tested is what a reader gets. The servers are removed when the test ends, panic or not, and readiness is a query that succeeds rather than a ping: MySQL answersmysqladmin pingwhile its initialisation is still running and the root password is not yet set. -
[generator]
./check --allsays how largetarget/e2ehas grown when it passes 15 GB. It is the cache the heavy tier shares, nothing evicts anything from it, and it once reached 47 GB and filled the disk — which left Docker's virtual disk read-only and no prune able to recover it. -
[language] Authorization is declared on the record.
@roles(ADMIN)guards every endpoint of a record;@reads(…)and@writes(…)split it between who may look and who may change, which is the common shape. Several roles mean any one of them will do. What protected a route until now was an edit to its handler's body —claims.require_role("ADMIN")— and an edit to a body is something a later edit can drop without anything noticing. What is generated instead is a parameter on the handler: one that does not take its guard does not compile against the route that needs it. A caller carrying no token gets401and one carrying the wrong role403, because say who you are and you may not are different answers. -
[language]
roles { ADMIN, MANAGER }, a declaration of its own, and every role a record names has to be in it. 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. Refused with the line the attribute is on, and the roles that were declared. -
[generator] A guarded model in a project with no
authsetting is refused before anything is written. A role is read from a caller's token and there is no token without an authentication module; left alone this generated a project that did not compile, pointing atcrate::authfrom a file its reader never wrote. -
[templates] The same record generates the same guard under
auth jwtand underauth oauth2. Both modules now expose one surface —Authenticated,Claims,AuthError— andauth-jwtgained the state plumbingauth-oauth2already had, so itsAuthenticatedworks in a handler the model generated rather than only inside its own router. -
[templates]
auth oauth2can be tested offline. A resource server holds no signing key, so a generated test had no way to obtain a token its own service would accept — which made every guarded route untestable under that module. There is now a test-only provider: an EC key fixture seeded into the key cache through the sameDecodingKey::from_jwka real JWKS document goes through. The fixture is compiled into the test binary and never into the one that serves. -
[templates] Every guarded record generates one more test, asking what happens to a caller who may not reach it:
401with no token,403with a token carrying a role the record does not name. -
[docs] ADR-0003 is accepted rather than provisional: a typed client in the core, UI applications as official blueprints. The reconfirmation it asked for waited on V1 usage feedback, and there are no users to hear from — 0.1.0 is tagged, unpublished, in a private repository. Feedback cannot arrive before a release, the release is not waiting on this, and the condition was blocking Phase 16 and with it the session half of Phase 12b. Taken now and revisitable on evidence, rather than deferred until evidence the deferral prevents. What the decision costs is said where a reader meets it, in the README and in the comparison table: Crabster does not promise a turnkey UI application, and that is the one place it does not aim at JHipster parity.
-
[templates]
client typescript, a new service setting, and with it a typed TypeScript client inclients/typescript/— a publishable npm package with no dependencies, sincefetchis in every runtime it targets. It covers every endpoint the model generates: list with paging, ordering and filters, find, create, patch and delete,If-Matchcarried for a@versionedrecord, and anApiErrorcarrying the RFC 9457 problem document a refusal answers with. Generated from the same model the server is, so its types and the API's answers agree by construction — not by reading the OpenAPI document that same model produces, which would make one a second implementation of the other with a JSON parse and a code generator in between. What holds them together is that the client is compiled understrictand put against the running server: create, list, find, patch, a stale version answered412, a missing record404, a referenced record refused409, then deleted. A model change reaches the client's types, which is checked rather than claimed. -
[generator]
FieldViewcarries what a field is in JSON, as TypeScript spells it, beside what it is in Rust. Two mappings written apart are two mappings that drift. -
[templates]
client rustgenerates a typed Rust client inclients/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 bycrabster import-cdlis services calling services, and this is what one calls another with. It names the crates behind the types —rust_decimal,chrono,uuid— and only those the model reaches for, not SeaORM's prelude: depending on an ORM to hold a date would make every caller of this API carry a database driver. Verified against the running server: create, list, find, patch, a stale version answered412, a missing record404, a referenced one refused409, then deleted — with the decimal and the timestamp crossing the wire and coming back as themselves. -
[templates]
client bothgenerates both clients. 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. -
[generator] A module's
[activation]may answer to more than one value, throughalso = [...]. Without it a setting could name only one module, so every combination would need a value of its own — andclient typescript-and-rustis a name for a list, not for a decision. -
[templates] 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.Orderis deliberately not among them, which is why the sort direction is calledDirection: a record calledOrderis 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. -
[templates]
session cookie, a new service setting, and with it a browser can sign in.auth oauth2alone makes a project a resource server: what arrives is a token about somebody, and whoever sent it had to get it first — a browser cannot, having nowhere safe to keep one. So the browser gets a cookie and the service keeps the token, which is the backend-for-frontend pattern and what lifts V1's exclusion of session-based flows. Three routes:/auth/loginredirects to the provider with Authorization Code and PKCE,/auth/callbackexchanges the code,/auth/logoutends the session on this side rather than asking the browser to forget. The cookie isHttpOnly,SameSite=Lax,Secureoutside thedevprofile, and carries an identifier — the tokens live in a table here, so a cross-site script has nothing to steal. -
[templates] A session reaches the rest of the project as the header it already reads. The bridge is a middleware and not a second extractor: a request carrying a session cookie has
Authorization: Bearerput on it before it reaches a route, so every@rolesguard and every handler written against a bearer token works with a cookie unchanged. It is added last in the router, becauselayerwraps what was added before it — written first it would leave exactly the routes it exists for unprotected. -
[templates] A callback that did not start here is refused. One arriving without the cookie that began the login, or with a
statethat is not the one that cookie's session holds, is a callback somebody else started — and the comparison is constant-time, since a comparison that stops at the first wrong byte says how many were right.?next=is followed only where it is a path on this site. -
[generator]
config/dev.tomlgained asectionsslot, so a module can say what it needs only on a developer's machine.secure = falseon the session cookie is the case: marked indev.tomlrather thandefault.toml, so forgetting it is a session that does not work locally rather than one sent in the clear in production. -
[examples] An official React blueprint, in
examples/blueprints/react.ui reacton a service generatesui/: a React application over the typed clientclient typescriptalready writes, one page per record, with paging, creation and deletion, and a form built from what the model says each field holds — a checkbox for aBool, a select for an enumeration, a number for a reference. It writes no client of its own, because two clients for one API would be two things to keep in step with it.npm run buildtype-checks the pages and the generated client together, so a model change that has not reached the front end is a build that fails rather than a screen that is quietly wrong. -
[examples] What "official" means, made testable rather than asserted. The blueprint is generated, installed and built by this repository's own gate, and then rendered in a real browser engine: the assertion is that rows the API holds are in the DOM, which only a mounted React tree that called the generated client could have put there. A blueprint that is merely linked to is a community blueprint, and the table in the guide says which is which.
-
[examples] The generated
vite.config.tssetsstrictPort. A dev server that moves to the next free port in silence makes a login fail somewhere neither the provider'sredirect_urinor this proxy mentions. -
[generator]
crabster introspect <url>writes a.cdlfrom a database that already exists — PostgreSQL, MySQL or SQLite. Every record, every field with its type and whether it may be absent, every reference read from the foreign keys socustomer_idbecomescustomer: ref Customer, every single-column uniqueness, and@versionedand@auditedrecognised by the columns they add. -
[generator] This is the one command in Crabster that connects to a database, and the dependency is the CLI's rather than the generator's. Everything else computes from the model and the generated project runs the migration; here the schema is the input and nothing but the database has it.
-
[generator] What introspection cannot recover is written at the top of what it writes, in the file rather than in a document nobody opens: an enumeration's variants, which are stored as text and so live in the rows;
@filterable, which is an API decision a table holds no trace of;@matchesand@range, checked before anything is stored; theserviceblock, which describes a deployment; and the lower half of a@length, since a column bounds how long a value may be and never how short. One thing may come back that nobody wrote — aTextfield with no bound is given one when the column is built, and a schema cannot tell a default from a decision. -
[generator]
./check --allaccepts one security advisory, and one only: RUSTSEC-2023-0071, the Marvin attack onrsa, medium and with no fixed version. It arrives throughsqlx-mysql, which reading a MySQL schema needs. That crate usesrsain one file and for one thing — encrypting a password with the server's public key during authentication — while Marvin recovers a private key through the timing of decryptions. This client holds no private key and decrypts nothing. The reasoning is written where the flag is, with the two commands that re-check it rather than trust it. -
[generator] An exact decimal is told from a float. SQLite records
decimal()asreal(19, 4), so the type name alone reads as an ordinary float and the exactness the model asked for is lost — it is the precision and scale together that say otherwise. Found by introspecting theshopexample and reading what came back. -
[language]
mongodbis a value ofdatabase, and the only one that is not a SQL dialect. What it selects is a backend rather than a fourth dialect, per ADR-0004: a document store has no migrations, no foreign keys and no schema to alter, so what differs is which templates run and not which branch inside one. -
[language] A reference in a model stored as documents is refused, with the line and column it was written on. A reference becomes a foreign key, declared inside a
CREATE TABLEand checked by the database on every write; a document store has neither, and a field that looks like a reference and is enforced by nothing only fails once the data is already wrong. -
[generator] A
record-coremodule, holding what a record API needs and a table does not explain: problem details, one page of results, and the serde helpers an update payload uses. Written once rather than twice — a document backend needs exactly the same, and two copies would be two things to keep in step. What is storage-specific leaves through three slots: the error variant a driver raises and its two mappings into a problem document. Moving the files changed nothing that comes out: every file of a generated project is byte-identical before and after. -
[generator] Not part of
core, deliberately: a project with no model would have gained a pagination helper and a listing type it never calls, which is the pile of unused code a record-less service used to arrive with. -
[templates]
database mongodbgenerates a working project: documents, CRUD, paging, ordering, filters,@unique,@versionedand@audited— and the API a caller sees is the one the SQL backend serves, endpoint for endpoint and refusal for refusal. An identifier is a number here too, handed out by acounterscollection: MongoDB's own is anObjectId, and letting that through would mean the typed clients, the UI, the OpenAPI document and a gateway's routes all had to ask where a record is kept before knowing what an identifier looks like. -
[templates]
@uniqueis an index built at startup rather than aCREATE TABLEa collection does not have — at startup and not at first write, because a uniqueness that appeared afterwards would let through the one duplicate it exists to stop.@versionedputs the version in the filter of the update, so the check and the write are one operation. -
[templates] Two stored types are not the ones the wire carries, and both would otherwise have failed quietly: a
Decimalis aDecimal128, so?sort=priceorders9.50before10.50, andBytesis a byte string rather than an array with one entry per byte. -
[templates] Thirteen tests per generated document project, green against a MongoDB the suite starts itself, and one in this repository that generates, compiles and runs them.
-
[templates] The generated Compose file names
mongo:7. MongoDB 8 refuses to start on a Linux kernel 6.19 or newer, which is what Docker Desktop's own VM runs — so a generated project would have come with a database that does not come up on the machine of whoever generated it. -
[templates]
search meilisearchgives every record a full-text endpoint,GET /api/<path>/search?q=…, answering the same page a list answers. It works over tables and over documents without a line of difference: what is indexed is the document the API answers with, so the module never asks where the records are kept. Meilisearch rather than Elasticsearch, and its REST API directly rather than a client crate whose release cycle would otherwise enter every generated project. -
[templates] 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. Indexing is best-effort and logs a failure: refusing to store a record because it could not be indexed would turn a search outage into a write outage. Searching is not — a search that cannot reach the index answers
503rather than an empty page, because an empty page reads as nothing matches. -
[templates] And a search made in the same breath as a write may be a moment behind it, around a tenth of a second: Meilisearch indexes as a task of its own. Said in the reference rather than left to be discovered — waiting for that task would put the index's latency on the path of every write, which is the coupling this avoids.
-
[templates] A generated suite starts its own Meilisearch, as it already started its own database. It used to search a fixed
127.0.0.1:7700, which meant it passed on a machine where somebody had started an engine by hand and failed everywhere else — a suite that the project it belongs to cannot run is not a suite.TEST_SEARCH_ADDRESSstill overrides it, for a CI job that runs one already. -
[templates] Each state built by a test gets indexes of its own, as it already had a database of its own. One engine is shared by a test binary, and a suite searching an index a previous run had filled passed while the backend under test had no indexing at all — which is how that gap was found.
Fixed🔗
-
[docs] The shop window was nine phases behind what the repository does. The README still introduced microservices, MongoDB, Kubernetes, OAuth2 and the typed clients as "after V1" while all of them run, and the comparison page listed four things "JHipster solved and Crabster has not" of which three were closed and the fourth half: related objects came back under
?expand=, the filter operators arrived with@filterable, search withsearch meilisearch, and@auditedcarriescreatedAtandupdatedAt— onlycreatedBystill needs an identity nothing ties yet. A comparison page ages in both directions, and the direction that undersells is the one nobody reports: a reader arriving after a release would have read a weaker product than the one they downloaded. Both pages are now checked against the repository by running it, and the comparison page says on which date. The README also stops promisingcargo install crabster-cli"from 0.1.0", which installs nothing while the crates are unpublished, and points at./checkrather than at the CI that no longer triggers. -
[generator] A service cannot be called what the system root already writes.
k8sandoverlaysare directories a system puts beside its services, and a service is a directory named after itself at the same level: the generation wrote the root, reached that service, and failed withtarget directory ... is not empty— an error naming a path rather than the clash that produced it, on a project by then half written. Refused before the first byte now, naming both. The reserved set is read out of what thesystemmodule generates, so a directory the templates start writing is taken the day they start writing it rather than the day somebody remembers to list it. -
[templates]
kubectl apply -f k8s/stopped working the momentk8s/gained akustomization.yaml, and the generated README, the manifests' comments and the commit that introduced it all went on claiming otherwise.apply -freads every.yamlin a directory, including that one, whose kind the API server does not know: it applies the whole system correctly and then exits non-zero about a missing CRD — a deployment that worked, reported as one that failed, in any script that checks an exit code. The instruction is-know, everywhere, and it needs nothing installed that-fdid not. -
[templates] The overlay scaled a service whose database is a file in its pod.
30-services.yamlpins those to one replica on purpose — a second replica is a second database, and a write is visible only to whichever pod received it — and the overlay's own comment said "exactly one of anything holding a volume" while its code filtered on something else entirely. Three pods, three files, nothing erroring. -
[templates] And it collapsed every Ingress onto one host in a system with no gateway, where there is one Ingress per service. The patch named a kind and no object, so it hit all of them; two objects claiming one host is resolved by the controller arbitrarily, leaving every service but one silently unreachable. One patch per object now, each naming what it is for.
-
[templates] The base restated the namespace and the image tags, and both did harm rather than nothing: a restated namespace let
apply -fandapply -kput the same directory in different places, and a restated tag silently reverted the edit the README tells you to make. Neither was needed — an overlay matches on what the rendered resources say. -
[examples] A
Dateor aUuidin a model generated a Diesel project that did not compile. Both reach the generated code as Diesel features, which teach Diesel to carry the types without putting the crates in the project's own graph — and every model the blueprint was first tried with happened to have neither. Declared now when the model uses them, with a test that generates a project with a date for no other reason. -
[templates] Two replicas starting together both ran the migrations, and PostgreSQL failed the loser with a duplicate key on
pg_type_typname_nsp_index— an index nobody wrote, naming nothing a reader of a generated project has ever heard of. The process exited 1; under Kubernetes it was restarted and the other had finished by then, so the rollout "succeeded" while crashing on every first deploy. Two at once is not exotic: it is a Deployment with two replicas, a scaled Compose file, and every rolling release. Migrations now run behind a lock the database hands out — a PostgreSQL advisory lock, a named MySQL one carrying the database so two projects on one server do not block each other — taken on a connection of their own, because a session lock released onto a pooled connection is a lock held until the process exits. -
[templates] A generated suite left its container running, every run.
testcontainersremoves a container when the handle is dropped, the handle lives in astatic, and astaticis never dropped — twelve of them were found running on one machine. Registering a destructor would have covered the ordinary exits and missed the one that matters, since nothing of ours runs after aSIGKILL; so the suite now spawns a reaper holding the read end of a pipe it never writes to. However the run ends, the kernel closes the pipe, the reaper wakes and removes the container. Nothing polls, and the tests assert that a finished run leaves nothing behind. Without anshto spawn — Windows — there is no reaper, and the old behaviour remains. -
[templates] A MongoDB project whose model filters nothing on a
Decimalgenerated a decimal conversion nothing called, so a freshly generated project compiled with a warning. It is generated where it is used now. -
[generator]
database mongodbused to generate a PostgreSQL project, migrations and all. The language named a value the generator did not interpret, and the fallthrough made it Postgres in silence — a model coming back as a project it does not describe. It is refused now, in words that say the templates are not written yet rather thanunknown module, which would send a reader looking for a blueprint. Nothing is written. -
[generator]
crabster new, thencrabster record, thencrabster upgradedeleted the whole API of the project. A project generated with no model recordscorealone; its first record is what brings persistence, and therecordmodule joined the render without joining the recorded list. The project was then saying it was made of something it was not, and nothing noticed until an upgrade — which renders from that list, found twelve files no module claimed, called them removed and deleted them. From a command whose owner had asked for nothing but the current templates. -
[generator] The root of a system could not be read back at all.
.crabster/project.tomlthere lists the single modulesystem, and reading a project insisted oncore— so no command that reads provenance would open a root, and an upgrade of one was impossible before it began. -
[generator]
cargo docpasses under-D warningsagain: three public doc comments linked to private items, which is an error rather than a warning and had gone unseen while CI was unavailable.
[0.1.0] - 2026-09-10🔗
The first release. Everything under this heading is what it contains, and the
templates it ships are 0.16.1 — the number a generated project records, and
the one a later crabster upgrade crosses.
Every phase of the V1 roadmap is complete: a
domain model written in CDL generates a Rust project that compiles
and serves a working CRUD API — paged, ordered and filtered — on any of
PostgreSQL, MySQL or SQLite, describes itself in OpenAPI, reports on itself
through health probes and Prometheus metrics, carries accounts and JWT sessions
when the model asks for them, ships a test suite that runs against the database
it targets, builds into an image and brings its own CI — and crabster record
adds a record to a project that already exists, without overwriting what was
written there by hand. Ahead of Phase 4, three
families of defect that belonged to none of those phases were closed: the
generator was not installable, three column types lost data on MySQL, and a
project deployed without APP_PROFILE came up listening on the loopback
address.
Fixed🔗
- [templates] A record that points at itself generates a project that
compiles and runs, and the templates version moves to
0.16.1. The language has always allowed it — a manager, a parent, a referrer — and documented it, but nothing had ever built one: the response carried a response of its own type with nothing to give it a size, so the project did not compile. It is boxed now. Boxed, it compiled and then overflowed the stack on startup, because describing that field in OpenAPI means describing the type it is in and utoipa followed it down; the field carriesno_recursion, and the document describes the recursion as a reference to the schema, which is what a recursive schema is meant to look like.?expand=answers one level deep, as it does for any other reference. Both failures were invisible to anything that did not build and run a generated project, so the test that covers this does.
Added🔗
-
[templates] SQLite is told what it has no
ALTER TABLEfor, by rebuilding the table, and the templates version moves to0.16.0. Whether a column may be empty, whether it is unique, and the foreign keys a table declares are all settled byCREATE TABLEon SQLite and cannot be altered after — so a change to any of them was refused. Crabster now does what SQLite documents: it builds the table the model describes beside the old one, copies the rows across, drops the old one and takes its name. One migration for the record, whatever else changed about it, and every row keeps itsid. A column that becomes mandatory still needs a--default, and one that goes still needs--force: a rebuild changes how the database is told, not what the change costs. -
[templates] A generated SQLite project applies its migrations on a connection of its own, with foreign keys off, and closes it before serving anything. This is what makes the rebuild above safe: with foreign keys on, SQLite rewrites the
REFERENCESclauses of every table pointing at the one being replaced, so they follow it out of existence without a word — andPRAGMA foreign_keysis a no-op inside a transaction, which is where a migration runs. A database held in memory is migrated on the serving connection instead, since an in-memory database belongs to the connection that opened it, and starts empty every time in any case. The pool that serves requests is unchanged, foreign keys and all. -
[templates] A type that only grows is migrated instead of refused:
Int→Long,Float→Double,Text→LongText. A type change is refused in general because moving values across is a question about data that only their owner can answer — but these three ask nothing: the new type holds every value the old one could, and the conversion is the identity. Everything else is still refused by name, including what only looks like a widening:Long→Doublecannot hold everyi64exactly, andInt→Decimalchanges how a value is read back. On SQLite nothing is written, all three pairs landing on a single affinity there. -
[templates] An optional reference reaches a table that already exists. Until now it was refused whatever the database, because a creation migration declares a foreign key inside
CREATE TABLE— the only place SQLite accepts one. PostgreSQL and MySQL attach one to a table that exists, so on those two the change is now a migration: the column, then the key, under the samefk_<table>_<column>name and the sameNoActionthe creation migrations use. Itsdowndrops the key before the column, which is the order MySQL requires. A mandatory reference is still refused, and by a different argument than the database's: the rows already in the table point at nothing, and no--defaultfixes it, because the value would have to name a row of the target that exists. Add it optional, fill it in, then make it mandatory. -
[templates] An
@lengthbound moves the column with it, and the templates version moves to0.15.0. Until now changing the upper bound was refused by name: the bound is the column's own width, not only a request check, and SQLite cannot change a column. But SQLite is also the one database of the three that records a width and enforces none of it — so there the bound was never the column's, and nothing needs migrating; the request check that regeneration rewrites is the whole of it. On PostgreSQL and MySQL the change now becomes a migration that restates the column at its new width. Widening is applied as asked; narrowing takes--force, because a value already longer than the new bound cannot be kept. -
[generator]
crabster upgrade, which carries a project already generated across a templates version. It regenerates from what.crabster/recorded and decides file by file, from the stamps: a file still exactly as Crabster wrote it is rewritten, a file its owner edited is left untouched with the new version put beside it in.crabster/incoming/, and a file the templates no longer produce is deleted only if nobody touched it. Protected regions are re-injected as onrecord, so a dependency you added crosses the version without counting as an edit.--dry-runsays what would move and writes nothing;--forcerewrites your files too, and is not the way to use it. Until now aTEMPLATES_VERSIONbump froze existing projects:recordrefused them and there was no command to cross. The check that refuses another version stays exactly where it was —upgradeis the one path allowed through it. -
[templates]
?expand=answers a reference in full, and the templates version moves to0.13.0. A list of orders carriedcustomerIdand nothing else, so anything showing a customer's name fetched one row per order — the N+1 the shape forced. Asking for?expand=customer,productnow answers the records themselves beside the identifiers, fetched in one query per reference for the whole page. The key is left out of the answer when there is nothing to put in it, so a client that reads these responses today reads them the same way tomorrow;customerIdbeside it distinguishes "nobody asked" from "the reference is empty". A name the record does not reference is refused, listing the ones it has. -
[templates] A filter vocabulary, and the templates version moves to
0.12.0.@filterablegave exact match and nothing else; a list now readsnotEquals,containson short text,greaterThan/greaterThanOrEqual/lessThan/lessThanOrEqualon anything ordered, andspecifiedon anything optional — asked for as?<field>.<operator>=, which is the shape JHipster settled on. The bare name still means exact match, so nothing that worked stops working. Long text and binary, which had no filter at all, now takespecified: whether there is anything there is the one question they admit. No comparison on text, because string order depends on the collation and the three databases do not agree on it. -
[templates] A list endpoint refuses a query parameter it does not read. It used to throw one away, so
?prce.greaterThan=100answered the whole table — a filter that never ran, and an answer with nothing in it to say so. The refusal names what was sent and lists what would have worked, which is what a?sort=naming no field already did. -
[templates]
@versionedand@audited, and the templates version moves to0.11.0. A record marked@versionedcarries aversioncolumn: reads answer anETag, andPATCHandDELETEmust send it back asIf-Match— a stale one gets 412, a missing one 428 (RFC 6585). The check and the write are one statement (WHERE id = ? AND version = ?), so nothing slips between them. Until now two clients changing the same row silently overwrote one another: the last write won and the first was gone with nothing said, which was the one place where generated code was wrong rather than merely incomplete. JHipster has never generated this either — the issues asking for it have been open since 2015.@auditedaddscreatedAtandupdatedAt, written by the code rather than by the database so all three behave the same. Both are asked for by the model rather than given to everyone, which is what letscrabster applyadd the columns to a project that already exists: a template that put them in every table could not, since the migration that created the table is frozen. -
[generator]
crabster apply, which brings a project into line with a model that changed. Until now a record could only be added whole: one more field on a record that already existed had no path at all, and editing.crabster/model.cdlto get one rewrote a migration the database had run. The command compares your.cdlwith the copy the project records and turns the difference into new migrations — an added column, a dropped one, a uniqueness index, a dropped table — numbered after the ones the project has, in a band of their own (n0001_…) so that no change migration can be mistaken for the creation of a record. The rest of the code is regenerated onupgrade's terms: rewritten where nobody wrote, merged where somebody did. A mandatory column needs--defaultfor the rows that already exist, and anything that destroys data needs--force. What no singleALTERexpresses on the database the project targets is refused by name rather than half-written — a type change, a@lengthbound, a reference added to a table that exists, and every column modification on SQLite, which has none. Verified against a running database: a row written before the change is still there afterwards, and answers with the new field. -
[generator] A migration's name is pinned to what the project recorded rather than derived from the current model. It was a record's position in the dependency order, so removing a record — or writing one at the top of the file — pushed every migration after it down a number, and a database that had run them would have seen the new names as migrations it had never run. Names now come from
files.toml, which already lists every file the project was given. -
[templates] A template under
_each_change/, rendered once per schema change and not at all by any other command, and the templates version moves to0.10.0for it. No file of an existing project changes because of it. -
[generator] Fixed: after one
crabster apply, four things went wrong at once and stayed wrong. A record's creation migration renders from the current model, so a single applied change made it differ from the file on disk for ever — and four behaviours were built on comparing the two.crabster recordread the difference as "you edited the model under an applied migration" and refused, permanently, on a project that had done nothing wrong.crabster upgradereported the file on every run and left a copy in.crabster/incoming/with advice that was actively wrong there.files.tomlrecorded the stamp of a rendering nobody wrote. And the migrationsapplyitself writes, which nothing renders a second time, dropped out offiles.tomland out of the listMigratorapplies — which would have renumbered the next one and left the previous one unapplied. A file that is written once is now not re-rendered, compared or reported at all, and keeps the stamp it has. -
[generator]
.crabster/applied.cdl: the model the migrations have actually built, recorded beside the model the project is generated from. The two move apart the moment somebody edits the model, and back together when a migration is written for the difference — which is what letsrecordandupgraderefuse a model declaring a column no migration creates, by name, instead of asking that question of the migration files and getting a wrong answer. A project generated before this reads the two as equal, which is what it was. -
[generator]
crabster applytakes no argument again, reading.crabster/model.cdl: editing the model the project records is the ordinary way to ask for one more field. A file is still accepted for anyone who keeps their.cdlelsewhere. It could not default to the recorded model before, because that was also what the difference was measured against. -
[generator] Fixed: the second
crabster applyon a project deleted the migrations the first one wrote.write_oncenamedsrc/migration/m*_*.rs, which is the band a record's ownCREATE TABLEis in — the migrationsapplywrites are in thenband and were never frozen at all. The second run produced only its own, found the first one's recorded and no longer produced, and removed them:mod.rsthen declared modules with no files, and a database that had run them held rows naming migrationsMigrator::upcould no longer find. The pattern is nowsrc/migration/*_*.rs, which is every numbered migration whichever band it is in. Found by running the walkthrough twice against a live database; everything compiles until the second run. -
[generator] A module can declare files that are written once and never rewritten, in
write_once, and the built-in ones do:recordfreezessrc/migration/m*_*.rs,auth-jwtfreezessrc/auth/migration.rs. SeaORM decides applied-or-pending by a migration's name alone (sea-orm-migrationbuilds a set of names from the database and a set from the files, and anything in both counts as applied), so rewriting one under an unchanged name changed what a project expected without changing its database, and said nothing — the failure arrived at the first request.crabster upgradenow leaves such a file alone whatever--forcesays, never deletes it, and puts what the templates would have written in.crabster/incoming/;crabster recordrefuses outright and names the file, which is what happens when somebody edits.crabster/model.cdlto add a field to a record that already exists — until now the only way to ask for that, and the one that corrupted. Verified against a real database: a project that had run its migrations and written a row still starts on that database and reads the row back. -
[templates]
.gitignoreignores.crabster/incoming/, and the templates version moves to0.9.0for it. One line, and versioned like any other template change: the number is what says which templates a project came from, andcrabster upgradeis now the way across it. Everything else under.crabster/stays committed — that is what lets the next upgrade tell your work from Crabster's. -
[generator]
.crabster/snapshot/, a copy of every generated file as Crabster wrote it, and the three-way merge it makes possible: on a file its owner edited,crabster upgradenow merges what the templates changed into what they wrote, instead of leaving the file alone with the new version beside it. The copy is the merge's common ancestor and there is no other way to have one — what the generator produced on a given day is not recoverable afterwards, its templates being compiled into the binary that wrote it. When the merge cannot decide, the file is still left exactly as it is and the merge goes to.crabster/incoming/withours/original/theirsmarkers: planting markers in a file Crabster did not write would leave a project outside git with no way back. It costs a repository one committed copy of its generated code — 42 files and 201 KB forexamples/shop.cdl, as much again as the project. A project generated before this upgrades exactly as it did before, and the first upgrade writes the copy, so the next one merges. -
[generator]
.crabster/project.tomlrecordsstamp = "fnv1a-skeleton-1", the identity of the way its stamps were computed. Every decision about an existing project comes from comparing a stamp; one computed another way answers nothing, andupgradenow says so instead of guessing. A project recording nothing was written before this line and used this same algorithm, so it is read as such — no project is stranded by the addition. -
[generator]
unknown modulenow names--blueprint. A project generated through a blueprint records that blueprint's modules, and a laterrecordorupgradewithout the flag could not resolve them; the message said what failed and not what to do about it. -
[templates] Protected regions. A template opens one with
{{ region(name="…") }}, and what you write between the markers survives regeneration byte for byte — it is not even run throughrustfmt. Four ship: dependencies of your own inCargo.toml, items at the end ofsrc/main.rs, routes that come from no record insrc/api/mod.rs, and settings inconfig/default.toml. Adding a dependency by hand is the most ordinary thing anyone does to a generated project, and until now it marked the file as edited and stoppedcrabster recorddead. -
[generator] A test that holds the reserved names against the templates that occupy them. It renders a probe model turning on every conditional path the templates have, reads the result with
syn, and requires every name a generated module imports to be one a model cannot take. The lists live incrabster-cdland the templates incrabster-codegen, and nothing made them agree: addinguse sea_orm::QuerySelect;to a template now fails a test that names the file and the identifier, rather than surfacing months later as a model that parses and will not compile. -
[generator] The CI job
packageable, which packages all three crates and rebuilds each from its own tarball. That second half is what catches a template tree living outside the package, and it had nothing watching it. -
[generator] The CI job
against-a-real-mysql. MySQL was selectable and never once started: it generates a model carrying the three types that render differently there, writes a row and reads it back — two of the three faults below let the write succeed and changed the value. -
[generator] Writing a blueprint, and an example blueprint in
examples/blueprints/gitlabthat does all four things the page describes: it adds a module, adds a file, writes into a slot in a file it does not own, and replaces a template with one that renders nothing — which removes it. A CI job generates with it and checks all four. -
[generator] A blueprint module declares the templates version it was written against, in
templates = "…", and is refused without one or with another. It writes against slots and variables that move, and the failure was otherwise a Tera error from inside a template saying nothing about the cause. Required now, while no third-party blueprint exists: requiring it later would break every one written in between. -
[templates] A
Dockerfileand a.dockerignore. Four stages, two of them there socargo-chefcan cache the dependency build; the final one isdistroless/cc, carrying the binary and itsconfig/and nothing else, asnonroot. Verified by building it and running it: the image serves, and readiness reaches the database from inside it. -
[templates]
.github/workflows/ci.yml: the formatter, Clippy at-D warnings, the tests against the database the project targets, a dependency audit, and a build of the image. No secret and no repository setting, so it passes on a repository that has just been created. The image is built and not pushed — where one belongs is a decision a generator cannot make — and the workflow says in a comment what to add. -
[templates] The Compose file gains the application, behind a profile:
docker compose up -d --waitstill starts the database alone, and--profile appbrings up both. -
[generator] Crabster's CI runs Clippy on generated code, and on both shapes of project. It checked
rustfmtand never Clippy — with the generated workflow now running-D warnings, every project would have watched its own CI fail on the day it was created. -
[templates]
src/testing.rs: the generated tests now run against the database the project targets. For PostgreSQL and MySQL that is a container the suite starts itself, with a database created per test — they count rows, and a shared one would make them depend on the order they ran in. For SQLite it is an in-memory database per test, needing no Docker.TEST_DATABASE_URLbypasses the container for a CI job that already runs a server. -
[templates] Every record gets three tests, including the ones that used to get none. A mandatory reference no longer disqualifies a record: its test creates the parent through the parent's own endpoint. Nor does a
@matches: the sample value is generated from the pattern and checked against it. What is still skipped is named — a pattern no value was found for, and the records that point at it. -
[templates] The
auth-jwtmodule: an accounts table,POST /auth/register,/auth/login,/auth/refresh,GET /auth/meandGET /auth/users, argon2 password hashing and stateless JWT sessions. Turned on byauth jwtin theserviceblock. A login that finds no account hashes against a dummy anyway, so a missing address is not measurably faster than a wrong password; a refresh token is refused where an access token is required, because both are signed with the same key and one lasts a month; and the signing key is refused twice — the placeholder outside thedevprofile, anything under 32 bytes everywhere — at startup rather than at the first login. -
[generator] Modules are turned on by a
servicesetting a module's manifest claims, through[activation]. There is no table of module names in the engine, which is what keeps a module reachable by being dropped in: the CDL parser no longer refuses an unknown setting — it cannot know what is installed — and the engine refuses it instead, with the line the parser kept and the list of settings the installed modules actually answer to.--with/--withoutoverride the model. -
[generator] A module declares the names its templates occupy, through
[[reserves]], and they are checked only while that module is generated:AuthUseris a name a project without accounts is free to take. -
[templates]
src/http.rsand an[http]configuration section: a request timeout answered as408, a concurrency limit, a body limit,nosniffon every response, and CORS that is closed by default with no way to say "any". No rate limiting, deliberately — see the roadmap. -
[templates] An OpenAPI 3.1 document at
/api-docs/openapi.json, and a console over it at/swagger-ui. Both are built from attributes on the handlers themselves, so a route and its description cannot drift apart without the compiler saying so. The Swagger UI assets are compiled into the binary rather than downloaded by a build script — a generated project builds with no network and serves with none. -
[templates]
.spectral.yaml, so the document can be linted with one command. It passes with nothing at any severity; one rule is off,info-contact, because who answers for an API is decided where it runs. -
[templates]
/problemsand/problems/{code}, which is where everytypein an error body leads. RFC 9457 asks that a type URI which is a locator have documentation behind it, and the text served is the same constant the error carries, so the two cannot drift. -
[generator] The CI job
the-api-contract: it starts a generated project, asks it for its own document, checks the console is served, and lints the document with the project's own ruleset at--fail-severity warn. -
[generator] Tests holding the document to what the project serves: no two operations may share an identifier — a generator produces a
listin every record's module — and every handler must carry its attribute. Verified in both directions. -
[templates]
/health/liveand/health/ready, and/healthkept as it was. The split is what an orchestrator needs: a restart policy reads liveness, which reaches nothing, and restarting a healthy process because a database went down turns one outage into two; a load balancer reads readiness, which reaches everything the service depends on, answers503when one cannot be reached, and names which. The probe is a round trip, not a look at the pool — a pool hands out a connection whether or not the server at the other end is still there. -
[templates]
/metrics, in the format Prometheus reads: counts, latencies and in-flight requests, labelled by the route that was matched, so the number of series is bounded by the size of the project rather than by its traffic. The health probes and/metricsitself are not counted. -
[templates] An
x-request-idon every request, minted when one arrives without it, written into every log line of that request, and returned on the response. -
[templates]
src/observability.rs, where those last two and the tracing setup now live, in one file. -
[generator]
examples/observability/, a Prometheus that scrapes a generated project, and the CI jobscraped-by-prometheusthat runs it — which is the Definition of Done of Phase 9, executed rather than asserted. -
[templates]
docker-compose.yml, for PostgreSQL and MySQL. It starts the database on exactly the host, port, user, password and database name.env.examplepoints at — both files are written from one value, and a test fails if they ever drift apart — socp .env.example .env,docker compose up -d --wait,cargo runneeds nothing filled in. SQLite gets no file: it is a file itself, and there is no server to start. Adding a service of your own goes in adocker-compose.override.yml, which Compose merges in on its own and which regenerating never touches. -
[templates] A SQLite project ignores its own database.
DATABASE_URLpoints at a file next to the README, starting the server creates it, and.gitignoresaid nothing about it or the two journal files beside it. -
[templates]
config/prod.toml, the file the default profile was missing. -
[generator]
crabster record <Name> --field "..."adds a record to a project Crabster generated, closing the last Phase 3 deliverable. It merges nothing: every generated file was stamped as it was written, and a file still matching its stamp is ours and may move. One that does not was edited by its owner, and the command stops, names it, and writes nothing — unless--force. Migrations already applied keep their numbers, a record may only be appended, a project whose files no longer match what it records is refused rather than half-updated, and so is one generated by another version of Crabster. A project fromcrabster new, with no model at all, gains its first record too. -
[generator] A generated project keeps
.crabster/:model.cdl, the.cdlit came from verbatim — comments included;project.toml, what the command line decided and no model can express; andfiles.toml, a stamp per generated file. The stamps are what make "was this edited?" answerable without re-rendering — a re-render runs throughrustfmt, an external binary found onPATHand answering to anyrustfmt.tomlin the working directory, and a toolchain without it made eighteen untouched files look hand-edited. Committed with the project, and the beginning of what ADR-0002 extends in Phase 11. -
[templates] Every list endpoint takes
?sort=<field>&order=asc|desc, the ordering the Phase 3 task list called for. The fields are a whitelist per record —id, then everything but long text and binary, then the reference columns — so nothing built from a query string reaches the database as a column, and an unknown field, or a direction that is neither, answers 400 listing what would have been accepted.orderon its own applies toidrather than being read and thrown away, and rows equal on the sorted field fall back toid, so paging through them never shows one row twice. -
[generator]
crabster new <name>with--path,--database,--port,--blueprintand--check, generating a project from thecoretemplate. -
[generator]
crabster import-cdl <file>generates a whole project from a domain model: SeaORM records and migrations, DTOs with validation, and a CRUD API per record. -
[generator] CDL parser, domain model and diagnostics that name the line, the column and what was expected.
-
[generator] Tera engine with layered module resolution. Built-in modules are embedded in the binary, blueprints are read from disk, and a blueprint overrides one template at a time.
-
[generator]
module.tomlmanifests declaring a module's requirements and the context variables it uses. A missing variable is reported by name, before rendering starts. -
[generator] Generated Rust is formatted with
rustfmt, best-effort. -
[templates]
coremodule: Axum server, layered configuration (per-profile TOML plusAPP__*environment overrides),tracinglogs (human-readable in dev, JSON elsewhere), graceful shutdown, and a/healthendpoint with a smoke test. -
[templates]
recordmodule: persistence, DTOs and endpoints, paginated, with exact-match filters for records marked@filterable. -
[templates] A
reffield becomes a foreign key: the column, the constraint and the SeaORM relations on both sides. -
[examples]
examples/shop.cdl, generated, compiled, run and exercised in CI — and a second model, written child-first, generated and run against a real PostgreSQL, since SQLite accepts a migration order the other two refuse.
Changed🔗
-
[templates]
.envis read the same way whether or not the project has a domain model, and what it answered now shapes the configuration error too: being told the file meant to supply your settings could not be read beats being told the settings are wrong. It was two shapes only because nothing in a model-less project used the answer. -
[generator] The templates moved from the workspace root to
crates/crabster-codegen/templates/, which is what makes the generator installable at all — see Fixed. A build script now rebuilds the crate when a template changes:include_dirtracks file contents throughinclude_str!, but not the directory listing, so adding a template left the previous build in place and a suite could run green against the templates of the build before. -
[generator] A generated project records two versions rather than one:
generatorandtemplates.crabster recordrefuses on the templates number, which is the one that decides whether a stamp still describes a file. A CLI fix that touches no template no longer costs anyone their project — which is the whole reasonCONTRIBUTING.mdversions the two apart. -
[generator]
ModelView::newtakes the target database. One column type depends on it (seeTimestampbelow), and branching in the templates was not an option — that is what the view exists to prevent. Breaking change to a public API, permitted before 1.0. -
[templates] An unset
APP_PROFILEnow meansprod, notdev. See Fixed. -
[templates] Generated code is Clippy-clean at
-D warnings. Three lints stood in the way:items_after_test_moduleinmain.rs, because a module's contributed helpers end with their tests and other items followed them, and two.err().expect()in the authentication tests. -
[generator] A module declares in its manifest the oldest Rust it compiles on, and the project takes the highest of them. Three files name that number — the manifest, the
Dockerfileand the CI the README suggests — and it now comes from one place; a test holds the first two together. -
[templates] The dev-dependencies no longer pin
sqlx-sqlite. That single line is what made "passes its integration tests on all three databases" untrue for two of them: a PostgreSQL project tested on SQLite, which is to say it tested a different project. The Phase 5 Definition of Done is met with nothing held back. -
[generator]
.crabster/project.tomlrecords the modules a project was generated from, andcrabster recordreads them back. Without it, regenerating anauth-jwtproject would report every file of it as one nobody asked for — and--forcewould then delete them. -
[generator]
ProjectConfigcarries the resolved module list, andgenerate_withtakes it from there rather than a module name. Breaking change to a public API, permitted before 1.0. -
[language] An unrecognised
servicesetting is no longer a parse error. It is kept, with its position, and refused bycrabster-codegen— which knows the installed modules and can therefore say what would have been accepted.CdlError::UnknownSettingis gone with it. -
[templates] Every failure answers RFC 9457 problem details as
application/problem+json, replacing{"status": …, "error": …, "fields": …}. Breaking change to the error contract of every generated project, and deliberately taken now: authentication adds 401 and 403 to that contract in the next phase, and changing it twice would be worse than changing it once.typeis what a client branches on and what the status cannot carry — a409for a taken unique value and a409for a reference that does not hold are different problems under one code. -
[language]
Problem,IntoParamsandToSchemaare reserved: the generated modules import them now, so a record or an enumeration taking one of those names would parse and then fail to compile. Found by the test added in the previous entry rather than by anyone reading the templates. -
[templates]
health::routestakes the dependencies readiness has to reach.src/health.rsis acorefile andcoreknows nothing about a database, so the probe that pings one is contributed by therecordmodule through slots in that file — the first place two modules write into one file because neither could hold the whole answer. A project with no domain model keeps the endpoint and has nothing to list. -
[templates]
DATABASE_URLnames127.0.0.1rather thanlocalhost. A MySQL client readslocalhostas "use the unix socket", which the Compose file beside it does not provide — so the same URL meant two different things depending on which of the two databases you had chosen. -
[generator] The two CI jobs that run a generated project against a real database now start that project's own
docker-compose.yml, instead of a service block written in the workflow. What CI runs is what the generated README tells a reader to run, and the Compose file is exercised on every run rather than being a file nobody executes. -
[language] CDL was redesigned. Records are declared
record X { name: Type }, a field is mandatory unless it ends in?, constraints are@attributestaking ranges (@length(2..120)), and a relationship is areffield written where the foreign key lives — the inverse is implied, so there is no separate relationship block. Options became attributes (@filterable), and the project settings block isservice. Nothing of the previous syntax is accepted. -
[templates] Every failure answers one JSON shape and a status that says which layer refused: 400 for a body that is not JSON or a parameter that will not read, 415 for the wrong content type, 413 for too large, 422 for JSON the model refuses. Validation failures moved from 400 to 422 accordingly, and the not-found and wrong-method fallbacks answer the same shape instead of an empty body.
-
[templates] Updating a record is
PATCH, notPUT. The body is a set of changes and an absent key leaves its field alone, which is whatPATCHmeans;PUTpromises to replace the resource. -
[generator] Minimum supported Rust version raised from 1.85 to 1.88, required by Tera 2. Caught by the MSRV job on the commit that added the dependency.
-
[templates] Generated projects declare their own
rust-version, which is not the generator's: 1.94 with persistence, set by SeaORM 2.0 and the sqlx behind it, and 1.88 without. Both are verified in CI.
Fixed🔗
-
[generator] The generator could not be installed.
include_dir!pointed at the workspace root, socargo packageshipped no templates at all — andinclude_dir!on a directory that is not there is a compile error, not an empty tree.cargo install crabster-cliwould have failed for every user, while every job in CI stayed green because they all build from a checkout. -
[templates] Three column types lost data, each on MySQL, and one on SQLite too. Measured against a real MySQL 8.4, not inferred:
total decimal(10,0)rounded 1234.56 to 1235 at INSERT with no error — nowdecimal(19, 4);payload binary(1)refused anything past one byte — nowblob; andplaced_at timestamprefused 2040 outright, besides shifting with the session's time zone — nowdatetimeon MySQL, which covers 1000-9999 and stores what it is given. -
[generator] A
Texttoo wide for a MySQL row is refused before theCREATE TABLEis. The rule was missing because the loop carrying the other text rule opens by skipping every field that is not@unique; it is a second pass now, and the@uniquemessage still wins where both apply. -
[templates] A project deployed without
APP_PROFILElistened on 127.0.0.1 and logged in unstructured DEBUG — a container answering nobody while its logs said it had started. The default isprod;.env.exampleselectsdev, which is the first line of the generated README anyway. -
[language] The refusal for an over-long
@lengthsaid "characters" where MySQL counts bytes, at up to four per character in utf8mb4. The documentation said it too, in both languages. -
[language] A field attribute — or a
?— written on a line of its own is refused, naming the field it would have qualified. Whitespace between a field and its suffix includes the newline, so@uniquewritten aboveemailattached to the field before it: the constraint landed on the wrong column, the project compiled, and nothing said so. A record's attribute is written on the line above, which is exactly what makes the mistake easy to make. -
[language] Names that parsed and then produced code that would not compile are refused, closing the language's own promise that a model which parses is a model that can be generated. A record becomes an
enumof the same name in its migration module, soTable,Migration,ColumnDef,SchemaManager,DbErrandResultshadowed what that file declares or names — the template now imports by name, asdomain/enums.rsalready did, and the reserved list is its import list rather than whatever the prelude re-exports. A@filterablerecord calledQuerydeclaredQueryFilterbeside theQueryFilterits API module imports. And an enumeration is now checked against all four modules that import enumerations by name rather than two, and against the request types a record generates —enum Clone,enum Validateandenum OrderResponsebesiderecord Ordereach produced a module with one name defined twice. -
[templates] A field named
activeno longer breaks the generated update handler. It bound the payload value to the field's own name, shadowing theActiveModelbeing built, soactive.active = Set(active)was a field access on aString. -
[templates] The migration module imports what it uses by name instead of through the prelude glob.
-
[generator] Module resolution walks the requirement graph on the heap. It recursed, so a blueprint whose modules formed a chain past roughly fifteen thousand links aborted the process on a stack overflow — not a failure the caller can report or the author can act on, and blueprints are third-party code. The two walks in
crabster-cdlwere made iterative for this reason already; this was the one left. Arustfmtthat dies mid-write is also reaped now rather than left as a zombie for the rest of the run. -
[templates] A migration names its table and columns explicitly rather than letting
DeriveIdenderive them from the Rust variant. The two are not the same name wheneversnake_casedoes not survive the trip throughPascalCase:line_1became the variantLine1and derived back toline1, andx_ybecameXYand derived back toxy. A record named that way moved its whole table. The result compiled, passed--check, and then failed onno such table— the schema and the entity disagreed, and nothing in between could see it. -
[language] Two references a migration would name alike are refused. A record referencing both
X1andX_1declared oneReferencedX1for the two, so the second foreign key silently pointed at the first one's table — accepted by every database, and invisible to the SeaORM relation, which named the right one. -
[language] Models that generated Rust which would not compile are now refused, naming the generated name: two records converging on one module (
BlogPostandblog_post), two fields on one column (ownerIdbesideowner: ref Owner), two enum values on one variant, a record taking a module the project already uses, two records served under one URL (pluralisation is not injective), two enumerations converging on one Rust type, two fields whose columns differ but whose migration variant or JSON key does not, and a relation named by both the field going forward and the record coming back. -
[language]
@filterablewritten twice,@uniqueon a type MySQL cannot index,@length(..0), a@rangebound too large for the field's type, and an enumeration named after a built-in type are all refused. A filterable field namedpageorsizeis too: every list endpoint already reads those for pagination, so the filter would have been set by the pagination parameter. So is an enumeration namedModel,Serializeor anything else a generated module already has: the name would be both declared and imported in one module, and the message says which of the two — a record's own module ordomain/enums.rs— already holds it. -
[generator] Three refusals apply only when the project targets MySQL, and moved to generation accordingly: the target is chosen there, and refusing in the grammar rejected on PostgreSQL a model PostgreSQL is happy with. They are
@uniqueonLongTextorBytes, which MySQL cannot index without a prefix length;@uniqueon aTextbounded above 768 characters, whose index key would pass the 3072-byte limit; and two references naming one foreign-key constraint, which MySQL requires unique across the schema while PostgreSQL scopes it per table and SQLite ignores it. -
[language]
@matchespatterns are compiled at parse time. An invalid one used to reach the generated project asRegex::new(..).expect(..)inside aLazyLock— a panic the first time that endpoint was called. -
[language]
@rangeon aFloatorDoubleemitted an integer literal wherevalidatorwants the field's own type, which did not compile. Negative and unstorable@lengthbounds are refused. -
[language] A second
serviceblock, a repeated setting and an attribute written twice on one field are refused. Each used to silently replace the first.databaseis validated in the parser, so a typo is reported at its line even when--databasewas going to override it, and port0is refused. -
[language] A reference cycle names the line of the reference that closes it, and a syntax error on the first line says what the top level accepts instead of naming a grammar rule.
-
[templates]
cp .env.example .env, the first line of every generated README, now does something: the file is read at startup. -
[templates] An explicit
nullon a mandatory field is refused instead of being read as "absent" and answered 200 without doing anything. -
[templates] Validation errors are keyed by the name the client sent (
fullName), not the Rust field name (full_name). -
[templates]
@length(..200)reaches the schema asstring_len(200)rather than an unbounded VARCHAR, and aTextfield with no bound gets 255 — what MySQL already applied on its own, while the other two left the column unbounded and the same model behaved differently on each. -
[templates] Migrations are applied in dependency order. The order was computed and then discarded — the vec SeaORM reads was declaration order, so any model not written parent-first created a foreign key against a table that did not exist yet. It survived CI because the example happens to be written in that order and only SQLite, which does not resolve the parent at
CREATE TABLEtime, was ever exercised. -
[generator]
crabster … --path .wrote over the current directory's files. The path normalised away to nothing,read_dir("")reported "not found", and the "must be empty" guard therefore took the create branch — after which every file was written relative to the process's own directory. -
[templates]
?page=no longer overflows the offset and panics the server, a misspelt key on aPATCHis refused instead of answering 200 having changed nothing, a list endpoint runs oneCOUNT(*)instead of two,POSTsends aLocationheader, and a malformed.envis reported instead of being reported as a missing variable. -
[templates]
src/domain/enums.rsis no longer written into projects with no enumeration, where nothing declared or read it. -
[generator] A blueprint now overrides one template at a time instead of replacing a whole module. Changing one file no longer means vendoring and maintaining every other file of that module.
-
[generator] A
--blueprintpath that does not exist, or holds no module, fails the command. It used to be ignored, generating the built-in project and reporting success while the author believed their blueprint had applied. -
[generator] A file that is not text — a
.DS_Store, say — no longer fails a whole blueprint module. -
[generator] Generation is all-or-nothing: templates are all rendered before anything is written, and a write failure rolls back what it created. Rollback removes the directories generation made, not only the last one:
--path a/b/cused to leaveaanda/bbehind.
Security🔗
- [generator] Template destinations that would escape the generated project are refused, and symlinks inside a blueprint are skipped rather than followed — including a module directory that is itself a link, which used to walk past the guard entirely. A blueprint is third-party code and must not reach outside itself.
- [templates] A failed database connection no longer logs
DATABASE_URLwith its password — including when the password contains a/, which defeated the first version of the redaction, and when it is given as a?password=query parameter, which carries no@to cut at. A malformed.envis reported by position only: the library's own message quotes the offending text, and on an unterminated quote that text is the rest of the file.
Notes🔗
- The project was called Rhipster until 2026-09-04. No release carried that name, so no migration is required. {% endraw %}