Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Alpha Note: Cloesce is under active development, expanding its feature set as it pushes towards full Cloudflare support, across any language. In this alpha, breaking changes can occur between releases.

The Cloesce Compiler converts object definitions into a full stack Cloudflare application.

Inspired by

Cloesce is not just a ORM, Migrations Engine, Web Framework, Runtime Validation Library, IaC tool, or API Generator. It is all of these things and more, wrapped in a clean paradigm that makes building Cloudflare applications a breeze.

@Model(["GET", "SAVE", "LIST"])
class User {
    id: Integer;
    name: String;
    posts: Post[];

    @KV("user/settings/{id}", namespace)
    settings: KValue<unknown>;

    @R2("user/avatars/{id}.png", bucket)
    avatar: R2Object;

    @POST
    async hello(): User {
        // D1, KV and R2 all hydrated here!
        return this;
    }
}
# Coming in a later release!
// Coming in a later release!

How simple can full stack development get?

Contributing

Contributions are welcome at all levels. Join our Discord to discuss ideas, report issues, or get help getting started. Create an issue on GitHub if you find a bug or have a feature request.

Coalesce

Check out Coalesce, an accelerated web app framework for Vue.js and Entity Framework by IntelliTect. Cloesce takes much of its inspiration from Coalesce (Cloesce = Cloudflare + Coalesce).

TypeDoc

An API reference generated using TypeDoc for the Cloesce TypeScript library can be found here.

Getting Started

Welcome to the Getting Started guide! This document will help you set up a basic Cloesce project using the create-cloesce template. We will discuss:

  • Installing Cloesce
  • A basic project structure
  • Building, running and deploying your Cloesce application

Cloesce runs entirely on Cloudflare Workers, so familiarity with Workers and Wrangler is recommended. If you are new to Workers, check out the Cloudflare Workers documentation.

Installation

Alpha Note: Cloesce supports only TypeScript to TypeScript compilation as of Alpha v0.1.0. Support for additional languages will be added in future releases.

The simplest way to get a Cloesce project up and running is to use the create-cloesce template.

This template sets up a basic Cloesce project structure with all the necessary dependencies and configurations, example Models, and example tests to help get you started quickly. The template includes a sample HTML frontend with Vite which should be replaced with your frontend of choice.

Prerequisites

  1. Sign up for a Cloudflare account
  2. Install Node.js (version 16.17.0 or later)

create-cloesce

To create a new Cloesce project using the create-cloesce template, run the following command in your terminal:

$ npx create-cloesce my-cloesce-app

After running this command, navigate into your new project directory:

$ cd my-cloesce-app

A simple project structure is set up for you:

├── src/
│   ├── data/           # Example Cloesce Models
│   └── web/            # Frontend web assets
├── tests/              # Unit tests for example Models
├── migrations/         # Database migration files
├── cloesce.config.json # Cloesce compiler configuration
└── package.json        # Project dependencies and scripts

Exploring the Template

After creating your project with create-cloesce, several example files are included to help you get started. Below is an overview of those files and their purpose.

Wrangler Environment

All Cloudflare Workers define a a set of bindings that provision resources such as D1 databases, R2 buckets, KV namespaces, and miscellaneous environment variables.

Cloesce uses a class decorated with @WranglerEnv to define the Wrangler Environment for your application, tailoring to the resources you need.

In src/data/main.ts, a basic Wrangler Environment has been defined.

@WranglerEnv
export class Env {
    db: D1Database;
    bucket: R2Bucket;
    myVariable: string;
}

The above implementation of Env defines a Wrangler environment with a D1 database binding named db, an R2 bucket named bucket, and a string environment variable named myVariable.

A typical Cloudflare Worker defines these bindings in a wrangler.toml file, but Cloesce generates this file for you during compilation based on the @WranglerEnv class.

Read more about the Wrangler Environment in the Wrangler Environment chapter.

Custom Main Function

Cloudflare Workers are serverless functions that run at Cloudflare’s edge and respond to HTTP requests. Each Worker defines an entry point function through which all requests are routed.

Cloesce allows this same functionality through a custom main definition (seen in src/data/main.ts)

export default async function main(
    request: Request,
    env: Env,
    app: CloesceApp,
    ctx: ExecutionContext
): Promise<Response> {...}

Just like the standard Workers entrypoint, this function receives the inbound Request, the Wrangler Environment defined by the decorated @WranglerEnv class, and an ExecutionContext for managing background tasks.

Additionally, it receives a CloesceApp instance that you can use to handle routing and Model operations.

Read more about custom main functions in the Middleware chapter.

Example Models

Models are the core building blocks of a Cloesce application. They define exactly how your data is structured, what relationships exist between different data entities, and what API endpoints will be generated to interact with that data.

Unlike other ORMs, Cloesce Models are not limited to just relational data stored in a SQL database. Models can also include data stored in R2 buckets, KV namespaces, or inject external services.

In src/data/Models.ts you will find two example Models, Weather and WeatherReport.

Weather Code Snippet
@Model()
export class Weather {
    id: Integer;

    weatherReportId: Integer;
    weatherReport: WeatherReport | undefined;

    dateTime: Date;
    location: string;
    temperature: number;
    condition: string;

    @R2("weather/photo/{id}", "bucket")
    photo: R2ObjectBody | undefined;

    static readonly withPhoto: IncludeTree<Weather> = {
        photo: {}
    }

    @POST
    async uploadPhoto(@Inject env: Env, stream: ReadableStream) {... }

    @GET
    downloadPhoto() {... }
}
WeatherReport Code Snippet
@Model(["GET", "LIST", "SAVE"])
export class WeatherReport {
    id: Integer;

    title: string;
    description: string;

    weatherEntries: Weather[];

    static readonly withWeatherEntries: IncludeTree<WeatherReport> = {
        weatherEntries: {}
    }
}

The Weather Model conists of:

FeatureType / DescriptionSource / Layer
idPrimary KeyD1
weatherReportOne-to-One relationshipD1
dateTimeScalar columnD1
temperatureScalar columnD1
conditionScalar columnD1
photoR2 object, key format weather/photo/{id}R2
withPhotoIncludeTreeCloesce
uploadPhotoAPI endpointWorkers
downloadPhotoAPI endpointWorkers

The WeatherReport Model consists of:

FeatureType / DescriptionSource / Layer
idPrimary KeyD1
titleScalar columnD1
summaryScalar columnD1
weatherEntriesOne-to-Many relationship with WeatherD1
withWeatherEntriesIncludeTreeCloesce
GETGenerated CRUD operationWorkers
SAVEGenerated CRUD operationWorkers
LISTGenerated CRUD operationWorkers

Read more about how models work in the Models chapter.

Building and Migrating

Building a Cloesce project generally consists of three steps:

  1. Compilation
  2. Running database migrations
  3. Building your frontend code.

Compiling

In your project directory, run the following command to compile your Cloesce Models:

$ npx cloesce compile

This command looks for a cloesce.config.json file in your project root, which contains configuration settings for the Cloesce compiler. If the file is not found, or settings are omitted, default values will be used.

After compilation, a .generated folder is created in your project root. This should not be committed to source control, as it is regenerated on each build. The folder contains:

  • cidl.json

    The Cloesce Interface Definition Language file, representing your data Models and their relationships. This file is used internally by Cloesce for generating client code, migrations, and running the Cloudflare Worker.

  • client.ts:

    The generated client code for accessing your Models from the frontend. Import this file in your frontend code to interact with your Cloesce Models over HTTP.

  • workers.ts:

    The generated Cloudflare Worker code with all linked dependencies (including your custom main function if defined). This file is the entry point for your Cloudflare Worker and is referenced in the generated wrangler.toml.

Alpha Note: wrangler.jsonc is not fully supported. Please use wrangler.toml for now.

Generating Migrations

To generate database migration files based on changes to your Cloesce Models, run the following command:

$ npx cloesce migrate <migration-name>

This command compares your current Cloesce Models against the last applied migration and generates a new migration file in the migrations/ folder with the specified <migration-name>. The migration file contains SQL statements to update your D1 database schema to match your Models.

You must apply the generated migrations to your D1 database using the Wrangler CLI:

$ npx wrangler d1 migrations apply <database-binding-name>

Running

After compiling and applying migrations, you can build and run your Cloudflare Worker locally using Wrangler:

$ npx wrangler dev --port <port-number>

Deploying

Alpha Note: Deployment is not yet enhanced by Cloesce and has room for improvement.

With your application built and your database migrated, you’re ready to deploy your Cloesce application to Cloudflare Workers. Deployment is done through the Wrangler CLI.

  1. Modify cloesce.config.json

    Ensure your cloesce.config.json file is correctly configured for production, including the production Workers URL.

    NOTE: Workers URLs must have some path component (e.g., https://my-app.workers.dev/api has /api).

  2. Configure Wrangler bindings

    Open your wrangler.toml and set all required binding IDs (e.g., kv_namespaces, d1_databases, r2_buckets) to their production values.

    Example:

    [[kv_namespaces]]
    binding = "kv"
    id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    
  3. Build your application

    Run the compile command to generate the necessary files for deployment:

    $ npx cloesce compile
    
  4. Deploy using Wrangler

    Publish your application to Cloudflare Workers:

    $ npx wrangler deploy
    
  5. Deploy your frontend

    If you have a frontend application (e.g., built with Vite), build and deploy it to your preferred hosting service. For example, with Cloudflare Pages

    $ npx wrangler pages deploy ./dist
    

Models

Models are used to define the:

  • Source of data (D1, KV, R2)
  • Structure of data (properties and relationships)
  • REST operations that can be performed on the data (CRUD methods, custom methods)
  • Generated Client API

A single Cloesce Model encapsulates an entity that exists across the stack. Suprisingly, they’re only a couple of lines of code!

In this chapter, we will explore the various features of Cloesce Models, including defining D1, KV and R2 backed properties, relationships between Models, generated CRUD methods, and custom methods.

Basic D1 Backed Model

In this section we will explore the basic properties of a D1 backed Model in Cloesce.

Cloudflare D1 is a serverless SQL database built on SQLite for Workers.

Defining a Model

Compilation in Cloesce is composed of three phases: Extraction, Analysis and Code Generation.

During Extraction, the compiler scans your source files (designated with *.cloesce.ts) for Model definitions. Models are defined using the @Model() decorator.

import { Model, Integer, PrimaryKey } from "cloesce/backend";

@Model()
export class User {
    @PrimaryKey
    id: Integer;

    name: string;
}

The above code defines a Model “User” with several properties:

PropertyDescription
UserCloesce infers from the class attributes that this model is backed by a D1 table User
idInteger property decorated with @PrimaryKey, indicating it is the model’s primary key.
nameString property representing the user’s name; stored as a regular column in the D1 database.

Models do not have constructors as they should not be manually instantiated. Instead, use the ORM functions to create, retrieve and update Model instances. For tests, consider using Object.assign() to create instances of Models with specific property values.

TIP: Using the @PrimaryKey decorator is optional if your primary key property is named id or <className>Id (in any casing, ie snake case, camel case, etc). The compiler will automatically treat a property named id as the primary key.

Supported D1 Column Types

Cloesce supports a variety of column types for D1 Models. These are the supported TypeScript types and their corresponding SQLite types:

TypeScript TypeSQLite TypeNotes
IntegerINTEGERRepresents an integer value
stringTEXTRepresents a string value
booleanINTEGER0 for false, 1 for true
DateTEXTStored in ISO 8601 format
numberREALRepresents a floating-point number
Uint8ArrayBLOBRepresents binary data

All of these types by themselves are NOT NULL by default. To make a property nullable, you can use a union with null e.g., property: string | null;. undefined is reserved for Navigation Properties and cannot be used to indicate nullability.

Notably, an Integer primary key is automatically set to AUTOINCREMENT in D1, so you don’t need to manually assign values to it when creating new records (useful for the ORM functions).

Migrating the Database

The standard Cloesce compilation command does not perform database migrations. To create or update the D1 database schema based on your Model definitions, you need to run the migration command:

$ npx cloesce compile # load the latest Model definitions
$ npx cloesce migrate <migration name>

TIP: Any change in a D1 backed Model definition (adding, removing or modifying properties, renaming Models, etc) requires a new migration to be created. The migration command will generate a new migration file in the migrations/ directory.

Navigation Properties

In the previous section, we built a basic D1 model with scalar properties. However, relational databases like Cloudflare D1 often involve more complex relationships between tables.

In this section, we will explore Navigation Properties which allow us to define relationships between different Models.

Foreign Keys

Before diving into Navigation Properties, it’s essential to understand their source: foreign keys. Foreign keys are scalar properties that reference the Primary Key of another Model, establishing a relationship between two Models.

Foreign keys directly translate to SQLite FOREIGN KEY constraints in the underlying D1 database.

For example, let’s say we want to create a relationship between Person and Dog, where a Person can have one Dog

import { Model, Integer, ForeignKey } from "cloesce/backend";

@Model()
export class Dog {
    id: Integer;
}

@Model()
export class Person {
    id: Integer;

    @ForeignKey(Dog)
    dogId: Integer;
}

The Person Model has a foreign key property dogId, which references the primary key of the Dog Model. This establishes a relationship where each person can be associated with one dog.

NOTE: Cloesce does not allow circular foreign key relationships (and neither does SQLite!).

If you need to model such a relationship, consider marking a foreign key as nullable and managing the relationship at the application level.

Inspired by Entity Framework relationship navigations, Cloesce allows you to effortlessly define One to One, One to Many and Many to Many relationships between your Models using Navigation Properties. All navigation properties are D1 backed Models themselves, or arrays of D1 backed Models.

Let’s revisit our Person and Dog Models and add navigation properties to them:

import { Model, Integer, ForeignKey, OneToOne } from "cloesce/backend";

@Model()
export class Dog {
    id: Integer;
}

@Model()
export class Person {
    id: Integer;

    @ForeignKey(Dog)
    dogId: Integer;

    @OneToOne<Dog>(p => p.dogId)
    dog: Dog | undefined;
}

In this example, we added a navigation property dog to the Person Model using the @OneToOne decorator.

This property allows us to access the associated Dog instance directly from a Person instance. The type of the navigation property is Dog | undefined, indicating that it may or may not be populated (elaborated on in the Include Trees section).

Just like in Entity Framework, omitting decorators is possible when specific naming conventions are followed. The above code can be reduced to:

import { Model, Integer } from "cloesce/backend";

@Model()
export class Dog {
    id: Integer;
}

@Model()
export class Person {
    id: Integer;

    dogId: Integer;
    dog: Dog | undefined;
}

Cloesce will automatically infer the relationship based on the property names in a similiar fashion to primary key inference. (dog matches dogId or dog_id in any casing).

One to Many

Let’s modify our Models to allow a Person to have multiple Dogs:

import { Model, Integer, ForeignKey, OneToMany } from "cloesce/backend";

@Model()
export class Dog {
    id: Integer;

    @ForeignKey(Person)
    ownerId: Integer;

    @OneToMany<Person>(d => d.ownerId)
    owner: Person | undefined;
}

@Model()
export class Person {
    id: Integer;

    @OneToMany<Dog>(d => d.ownerId)
    dogs: Dog[];
}

In this example, we added a foreign key ownerId to the Dog Model, referencing the Person Model. The Person Model now has a navigation property dogs, which is an array of Dog instances, representing all dogs owned by that person.

We can omit decorators for OneToMany only if a single ForeignKey exists pointing from Dog to Person. Thus, the above code can be simplified to:

import { Model, Integer } from "cloesce/backend";
@Model()
export class Dog {
    id: Integer;

    ownerId: Integer;
    owner: Person | undefined;
}

@Model()
export class Person {
    id: Integer;

    dogs: Dog[];
}

Many To Many

Many to Many relationships have an intermediate junction table that holds foreign keys to both related Models.

import { Model, Integer } from "cloesce/backend";
@Model()
export class Student {
    id: Integer;

    courses: Course[];
}

@Model()
export class Course {
    id: Integer;

    students: Student[];
}

An underlying junction table will be automatically created by Cloesce during migration:

CREATE TABLE IF NOT EXISTS "CourseStudent" (
  "left" integer NOT NULL,
  "right" integer NOT NULL,
  PRIMARY KEY ("left", "right"),
  FOREIGN KEY ("left") REFERENCES "Course" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
  FOREIGN KEY ("right") REFERENCES "Student" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);

Note: The left column lists the model name that comes first alphabetically; the right column lists the one that comes after.

Include Trees

If you try to fetch a Model instance that has Navigation Properties or KV and R2 attributes, you will notice that they are not populated by default (they will be either empty arrays or undefined). This is intentional and is handled by Include Trees.

What are Include Trees?

Include Trees are Cloesce’s response to the overfetching and recursive relationship challenges when modeling a relational database with object-oriented paradigms.

For example, in the Model definition below, how should Cloesce know how deep to go when fetching a Person and their associated Dog?

import { Model, Integer } from "cloesce/backend";

@Model()
export class Dog {
    id: Integer;

    ownerId: Integer;
    owner: Person | undefined;
}

@Model()
export class Person {
    id: Integer;

    dogs: Dog[];
}

// => { id: 1, dogs: [ { id: 1, owner: { id: 1, dogs: [ ... ] } } ] } ad infinitum

If we were to follow this structure naively, fetching a Person would lead to fetching their Dog, which would lead to fetching the same Person again, and so on, infinitely.

Include Trees allow developers to explicitly state which related Navigation Properties should be included in the query results, preventing overfetching. If a Navigation Property is not included in the Include Tree, it will remain undefined (for singular relationships) or an empty array (for collections).

A common convention to follow when writing singular Navigation Properties is to define them as Type | undefined, indicating that they may not be populated unless explicitly included.

All scalar properties (e.g., string, number, boolean, etc.) are always included in query results. Include Trees are only necessary for Navigation Properties.

Alpha Note: No default include behavior is implemented yet. All Navigation Properties must be explicitly included using Include Trees.

Creating an Include Tree

We can define Include Trees to specify that when fetching a Person, we want to include their dogs, but not the owner property of each Dog:

import { Model, Integer, IncludeTree } from "cloesce/backend";
@Model()
export class Dog {
    id: Integer;

    ownerId: Integer;
    owner: Person | undefined;
}

@Model()
export class Person {
    id: Integer;
    dogs: Dog[];

    static readonly withDogs: IncludeTree<Person> = {
        dogs: {
            owner: {
                // Left empty to signal no more includes
                // ...but we could keep going!
                // dogs: { ... }
            }
        }
    };
}

In this example, we defined a static property withDogs on the Person Model that represents an Include Tree. This tree specifies that when fetching a Person, we want to include their dogs, but we do not want to include the owner property of each Dog.

During Cloesce’s extraction phase, the compiler recognizes the IncludeTree type and processes the structure accordingly.

Client code generation will then have the option to use this Include Tree when querying for Person instances. See the Cloesce ORM chapter and Model Methods for more details on how to use Include Trees in queries.

Include Trees are not limited to only D1 backed Models; they can be used with KV and R2 as well.

KV and R2 Properties

D1 is a powerful relational database solution, but sometimes developers need to work with other types of storage for specific use cases. Cloesce supports integrating Cloudflare KV and Cloudflare R2 storage directly into your Models, allowing you to leverage these storage solutions alongside D1 databases.

Defining a Model with KV

Cloudflare KV is a globally distributed key-value storage system. Along with a key and value, KV entries can also have associated metadata.

Cloesce respects the design constraints of KV storage. For models backed purely by KV or R2, the following are not supported:

  • Relationships
  • Navigation properties
  • Migrations
import { Model, KV, KValue, KeyParam, IncludeTree } from "cloesce/backend";

@Model()
export class Settings {
    @KeyParam
    settingsId: string;

    @KV("settings/{settingsId}", "myNamespace")
    data: KValue<unknown> | undefined;

    @KV("settings/", "myNamespace")
    allSettings: KValue<unknown>[];

    static readonly withAll: IncludeTree<Settings> = {
        data: {},
        allSettings: {}
    };
}

The above Model uses only KV attributes. The @KeyParam decorator indicates that the settingsId property is used to construct the KV key for the data property, using string interpolation. The @KV decorator specifies the key pattern and the KV namespace to use.

The data property is of type KValue<unknown>, which represents a value stored in KV. You can replace unknown with any serializable type, but Cloesce will not validate or instantiate the data when fetching it.

The allSettings property demonstrates how Cloesce can fetch via prefix from KV. This property will retrieve all KV entries with keys starting with settings/ and return them as an array of KValue<unknown>.

Include Trees can be used with KV Models as well to specify which properties to include when fetching data. By default, no properties are included unless specified in an Include Tree.

Note: KV properties on a Model consider a missing key as a valid state, and will not return 404 errors. Instead, the value inside of the KValue will be set to null.

Note: unknown is a special type to Cloesce designating that no validation should be performed on the data, but it is still stored and retrieved as JSON.

Alpha Note: KV Models do not yet support cache control directives and expiration times. This feature is planned for a future release.

Defining a Model with R2

Cloudflare R2 is an object storage solution similar to Amazon S3. It allows you to store and retrieve large binary objects.

Just like in KV Models, Cloesce does not support relationships, Navigation Properties, or migrations for purely R2 backed models.

Since R2 is used for storing large objects, the actual data of an R2 object is not fetched automatically when accessing an R2 property to avoid hitting Worker memory limits. Instead, only the metadata of the R2Object is retrieved. To fetch the full object data, you can use Model Methods as described in the chapter Model Methods.

import { Model, R2, R2Object, KeyParam, IncludeTree } from "cloesce/backend";

@Model()
export class MediaFile {
    @KeyParam
    fileName: string;

    @R2("media/{fileName}.png", "myBucket")
    file: R2Object | undefined;

    static readonly withFile: IncludeTree<MediaFile> = {
        file: {}
    };
}

The MediaFile Model above is purely R2 backed. The @KeyParam decorator indicates that the fileName property is used to construct the R2 object key for the file property. The @R2 decorator specifies the key pattern and the R2 bucket to use.

The file property is of type R2Object, which represents an object stored in R2. This type provides access to metadata about the object, such as its size and content type.

Include Trees can also be used with R2 backed Models to specify which properties to include when fetching data.

Note: R2 properties on a Model consider a missing object as a valid state, and will not return 404 errors. Instead, the property will be set to undefined.

Mixing Data Together

Cloesce allows you to combine D1, KV and R2 properties into a single Model. This provides flexibility in how you structure your data and choose the appropriate storage mechanism for each property.

import { Model, Integer, KV, KValue, R2, R2Object, KeyParam, IncludeTree } from "cloesce/backend";

@Model()
export class DataCentaur {
    id: Integer;

    @R2("centaurPhotos/{id}.jpg", "myBucket")
    photo: R2Object;
}

@Model()
export class DataChimera {
    id: Integer;
    
    favoriteSettingsId: string;

    dataCentaurId: Integer;
    dataCentaur: DataCentaur | undefined;

    @KV("settings/{favoriteSettingsId}", "myNamespace")
    settings: KValue<unknown>;

    @R2("media/{id}.png", "myBucket")
    mediaFile: R2Object | undefined;

    static readonly withAll: IncludeTree<DataChimera> = {
        dataCentaur: {
            photo: {}
        },
        settings: {},
        mediaFile: {},
    };
}

In the DataChimera Model above, we have a mix of D1, KV, and R2 properties. The id property is stored in a D1 database, while the settings property is stored in KV and the mediaFile property is stored in R2.

Mixing these storage mechanisms introduces some caveats. Whenever D1 is used in a Model, it is treated as the source of truth for that Model. This means that if the primary key does not exist in D1, the entire Model is considered non-existent, even if KV or R2 entries exist for that key.

However, if a primary key exists and the KV and R2 entries do not, Cloesce considers this a valid state and will place null or undefined in those properties respectively.

Further, using KeyParams in a Model with D1 limits the capabilities of the ORM, discussed later in this chapter. It is recommended to avoid using KeyParams in Models that also use D1 Navigation Properties.

Model Methods

Models do not define just the source, structure and infrastructure of your data, they also define the API endpoints to operate on that data.

In this section, we will explore how to define methods on Models that are exposed as API endpoints, CRUD methods generated by Cloesce, and the runtime validation that occurs when these methods are invoked.

Static and Instance Methods

A Model class in Cloesce may have both static and instance methods.

A static method is the most simple, it simply exists on the same namespace as the Model class itself. An instance method exists on an actual instance of the Model.

Methods must be decorated with a HTTP Verb such as @GET, @POST, etc. to be exposed as API endpoints.

import { Model, Integer, HttpResult } from "cloesce/backend";

@Model()
export class User {
    id: Integer;
    name: string;

    @GET
    static echo(input: string): HttpResult<string> {
        if (isBadWord(input)) {
            return HttpResult.fail(400, "I'm not saying that!");
        }

        return HttpResult.ok(`Echo: ${input}`);
    }

    @GET
    greet(): string {
        return `Hello, my name is ${this.name}.`;
    }

    foo() {
        // Not exposed as an API method
    }
}

After compilation via npx cloesce compile, the above Model will have two API endpoints:

  • GET /User/echo?input=yourInput Calls the static echo method.
  • GET /User/{id}/greet Calls the instance greet method on the User instance with the specified id.

To query these endpoints, a full generated client that matches the exact structure defined in the Model is available after compilation in .generated/client.ts.

Alpha Note: GET methods currently do not support complex types as parameters (such as other Models, arrays, etc). Only primitive types like string, number, boolean, etc are supported. This limitation will be lifted in future releases.

CRUD Methods

When creating Models, you will find yourself writing the same CRUD (Create, Read, Update, Delete) boilerplate. To save this effort, Cloesce automatically generates standard CRUD methods if included in the Model decorator. These methods are exposed as API endpoints. Internally, they simply run the Cloesce ORM operations available via the Orm class.

import { Model, Integer } from "cloesce/backend";

@Model(["GET", "SAVE", "LIST"])
export class User {
    id: Integer;
    name: string;
}

The above User Model will have the following API endpoints generated automatically:

  • GET /User/{id}/GET?__dataSource= Fetch a User by its primary key.
  • POST /User/SAVE?__dataSource= Create or update a User. The User data is passed in the request body as JSON.
  • GET /User/LIST?__dataSource= List all User instances.

All CRUD methods take an optional IncludeTree in the request to specify which navigation properties to include in the response. This defaults to no includes.

Note: R2 does not support CRUD methods for streaming the object body, instead it only sends the metadata.

Alpha Note: Delete is not yet supported as a generated CRUD method.

Runtime Validation

When a Model method is invoked via an API call, the Cloesce runtime automatically performs validation on the input parameters and the return value based on what it has extracted from the Model definition during compilation. This ensures that the data being passed to and from the method adheres to the expected types and constraints defined in the Model.

There are many valid types for method parameters in Cloesce, such as:

TypeDescription
stringString values
numberFloating-point numbers
IntegerInteger values
booleanBoolean values (true/false)
DateDate and time values
Uint8ArrayBinary data
DataSourceOf<T>Any data source for Model type T
unknownJSON data of unknown structure
DeepPartial<T>Partial version of Model type T where anything can be missing
Plain Old ObjectsObjects with properties of supported types
Model typesCustom Models (e.g., User, Post)
ArraysArrays of any supported type (e.g., string[], User[])
Nullable unionsNullable versions of any type (e.g., string | null, User | null)
HttpResult<T>HTTP result wrapping any supported type T
ReadableStreamStream of data

Plain Old Objects

Cloesce supports the use of Plain Old Objects (POOs) as method parameters and return types. A POO is an object with properties that are of supported types. This allows developers to return or accept complex data structures without needing to define a full Model for them.

import { Model, Integer, HttpResult } from "cloesce/backend";

export class Profile {
    bio: string;
    age: Integer;
    interests: string[];
}

@Model()
export class User {
    id: Integer;
    name: string;

    @POST
    updateProfile(profile: Profile): HttpResult<string> {
        // Update the user's profile with the provided data
        return HttpResult.ok("Profile updated successfully.");
    }
}

Note: Plain old objects can only consist of serializable properties supported by Cloesce. They must be exported so that they can be linked. They cannot contain streams.

HttpResult

Every method response in Cloesce is converted to a HttpResult internally. This allows methods to have fine-grained control over the HTTP response, including status codes and headers.

DeepPartial

Cloesce provides a special utility type called DeepPartial<T>, which allows for the creation of objects where all properties of type T are optional, and this optionality is applied recursively to nested objects. This is particularly useful for update operations where you may only want to provide a subset of the properties of a Model.

Stream Input and Output

Cloesce supports streaming data both as input parameters and return values in Model methods. This is particularly useful for handling large files or data streams without loading everything into memory at once. Streams can be hinted to Cloesce using the ReadableStream type.

If a method parameter is of type ReadableStream, no other validation is performed on the input data. Additionally, no other parameters are allowed in the method signature when using a stream input (aside from injected dependencies, which are discussed later).

When a method returns a ReadableStream, Cloesce will return a plain Response on the client side, allowing for efficient streaming of data back to the client.

Cloesce allows a HttpResult<ReadableStream> to be returned as well, which provides the ability to set custom status codes and headers while still streaming data.

For example, to return a R2 object’s body as a stream, you can define a method like this:

import { Model, Integer, R2Object, GET, HttpResult } from "cloesce/backend";

@Model()
export class MediaFile {
    id: Integer;

    @R2("media/{id}.png", "myBucket")
    r2Object: R2Object | undefined;

    @GET
    downloadFile(): HttpResult<ReadableStream> {
        if (!this.r2Object) {
            return HttpResult.fail(404, "File not found.");
        }

        // Body is never loaded into memory, just streamed!
        return HttpResult.ok(this.r2Object.body);
    }
}

Cloesce ORM

Alpha Note: The ORM is subject to change as new features are added.

During the hydration step of the Cloesce runtime, all of a Models data is fetched from it’s various defined sources (D1, KV, R2) and combined into a single object instance. This unified object can then be used seamlessly within your application code.

Luckily, Cloesce doesn’t keep this functionality to itself, it is made available through the Orm class in the cloesce/backend package.

Getting and Listing Models

Cloesce provides two basic methods to select a Model from D1, KV and R2:

import { Orm } from "cloesce/backend";
import { User } from "@data"

const orm = Orm.fromEnv(env);
const user = await orm.get(User, {
    id: 1,
    keyParams: {
        myParam: "value"
    },
    includeTree: User.withFriends
});
// => User | undefined

const users = await orm.list(User, User.withFriends);
// => User[]

Note that the get method requires the primary key of the Model to be passed in, along with any key parameters needed to construct KV or R2 keys.

The list method simply takes an optional Include Tree to specify which navigation properties to include. This means that the list method cannot be used with Models that require key parameters for KV or R2 properties (try using prefix queries instead).

Select, Map and Hydrate

Typically, when using a relational database, you require more advanced filtering capabilities. Instead of creating a DSL for querying models (such as LINQ) or advanced libraries like Drizzle, Cloesce takes a stance that when you need to do a SQL query– write it in SQL.

However, the logic of LEFT JOINing related tables based on navigation properties can be tedious and error prone. Additionally, some way to turn the flat result set of a SQL query into JSON objects, and some way to turn those JSON objects into fully fledged Model instances with KV and R2 properties populated is needed.

The select method generates the appropriate SQL query to fetch the desired data from D1, generating joins for navigation properties based on the provided Include Tree. It also aliases the selected columns to match the object graph structure, which is useful for filtering.

Let’s create a simple set of Models to demonstrate this:

@Model()
export class Boss {
    id: Integer;
    persons: Person[];

    static readonly withAll: IncludeTree<Boss> = {
        persons: {
            dogs: {},
            cats: {}
        }
    };
}

@Model()
export class Person {
    id: Integer;
    bossId: Integer;
    dogs: Dog[];
    cats: Cat[];
}

@Model()
export class Dog {
    id: Integer;
    personId: Integer;
    Person: Person | undefined;
}

@Model()
export class Cat {
    id: Integer;
    personId: Integer;
    Person: Person | undefined;
}

Using the select ORM method with the Boss.withAll Include Tree will generate the following SQL:

SELECT 
    "Boss"."id" AS "id",
    "Person_1"."id" AS "persons.id",
    "Person_1"."bossId" AS "persons.bossId",
    "Dog_2"."id" AS "persons.dogs.id",
    "Dog_2"."personId" AS "persons.dogs.personId",
    "Cat_3"."id" AS "persons.cats.id",
    "Cat_3"."personId" AS "persons.cats.personId"
FROM "Boss"
LEFT JOIN "Person" AS "Person_1" 
    ON "Boss"."id" = "Person_1"."bossId"
LEFT JOIN "Dog" AS "Dog_2" 
    ON "Person_1"."id" = "Dog_2"."personId"
LEFT JOIN "Cat" AS "Cat_3" 
    ON "Person_1"."id" = "Cat_3"."personId"

Utilizing the aliased results in a CTE expression allows for easy filtering based on navigation properties:

const selectSql = Orm.select(User, {
    includeTree: Boss.withAll
});

const query = `
    WITH BossCte AS (
        ${selectSql}
    )
    SELECT * FROM BossCte WHERE
        [persons.dogs.id] = 5
    AND
        [persons.cat.id] = 10
    AND
        [persons.id] = 15
`;

This SQL can be executed on a D1 instance, and the results passed to the map method to convert the flat result set into JSON objects:

const results = await d1.prepare(query).all();
const bosses = Orm.map(Boss, results, Boss.withAll);
// => Boss[]

Finally, the hydrate method can be used to take these JSON objects and convert them into fully fledged Model instances, with KV and R2 properties fetched and populated:

const orm = Orm.fromEnv(env);
const hydratedBosses = await orm.hydrate(Boss, {
    base: bosses,
    keyParams: {...},
    includeTree: Boss.withAll
});
// => Boss[]

Note: Orm.map requires the input results to be in the exact aliased format generated by Orm.select. Mixing and matching with other SQL queries may fail.

Saving a Model

Cloesce combines posting and editing a Model into a single method upsert. Upsert is capable of creating or inserting complex object graphs including D1 and KV properties. R2 properties are not supported for upsert since they typically involve large binary data that is better handled separately.

import { Orm } from "cloesce/backend";
import { User } from "@data"
const orm = Orm.fromEnv(env);
const result = await orm.upsert(User, {
    // id: 1, Assume User.id is an integer, we can auto-increment it
    name: "New User",
    friends: [
        {
            // Again assume Friend.id is an integer
            name: "Friend 1"
        },
        {
            id: 1, // Existing Friend
            name: "My Best Friend" // Update existing Friend name
        },
    ]
}, User.withFriends);

Upsert would then return the newly created User instance, complete with assigned primary keys and any navigation properties specified in the Include Tree, along with the newly created Friends.

Wrangler Environment

Note: Unlike some Infrastructure as Code tools for Cloudflare, Cloesce does not try to fully replace using a Wrangler configuration file. Account specific configurations settings such as binding IDs must still be managed manually.

Cloesce will search your project for a class decorated with @WranglerEnv to define the Cloudflare Workers environment tailored to your application.

A wrangler.toml file is generated during compilation based on this class. Currently, only D1 databases, R2 buckets, KV namespaces and string environment variables are supported.

Cloesce will not overwrite an existing wrangler.toml file, or any unique configurations you may have added to it. It will append any missing bindings and variables defined in the @WranglerEnv class.

An instance of the WranglerEnv is always available as a dependency to inject. See the Services chapter for more information on dependency injection.

An example WranglerEnv class is shown below:

@WranglerEnv
export class Env {
    db: D1Database;
    bucket: R2Bucket;
    kv: KVNamespace;
    someVariable: string;
}

This will compile to the following wrangler.toml file:

compatibility_date = "2025-10-02"
main = ".generated/workers.ts"
name = "cloesce"

[[d1_databases]]
binding = "db"
database_id = "replace_with_db_id"
database_name = "replace_with_db_name"

[[r2_buckets]]
binding = "bucket"
bucket_name = "replace-with-r2-bucket-name"

[[kv_namespaces]]
binding = "kv"
namespace_id = "replace_with_kv_namespace_id"

[vars]
myVariable = "default_string"

Alpha Note: Only one D1 database binding is currently supported. Future releases will allow multiple D1 bindings.

Services

Models are not the only way to write API logic in Cloesce.

Services are another core concept that allow you to encapsulate business logic and share it across your application. Services are similar to Models in that they can define methods that can be called from your API routes, but they do not have any associated data storage or schema.

Instead, Services are used to group complex related functionality together and can be injected into other parts of your application using Cloesce’s dependency injection system.

TIP: A clean design pattern for Cloesce is to use Services to encapsulate significant business logic and have Models act as thin wrappers around data storage and retrieval. This separation of concerns can lead to more maintainable and testable code.

Hello World Service

Let’s create a simple Service that returns a “Hello, World!” message.

import { Service, GET } from 'cloesce/backend';

@Service
export class HelloWorldService {

    init(): HttpResult<void> | undefined {
        // Optional initialization logic can go here
    }

    @GET
    hello(): string {
        return "Hello, World!";
    }
}

After running npx cloesce compile, this Service will be available at the endpoint HelloWorldService/hello, and a client method will be generated for you to call it from the frontend.

Dependency Injection

To share dependencies across your Cloesce application methods, Cloesce utilizes dependency injection. By default, Cloesce provides two dependencies:

  • Wrangler Environment: Access to your Cloudflare Workers environment variables.
  • Request: The incoming HTTP request object.

You can access these dependencies by decorating your method parameters with the @Inject decorator on any Cloesce Model or Service method:

import { Service, GET, WranglerEnv } from 'cloesce/backend';

@WranglerEnv
class Env {
    d1: D1Database;
}

@Service
export class HelloWorldService {
    @GET
    async hello(@Inject env: Env, @Inject request: Request): Promise<string> {
        console.log("Request URL:", request.url);
        const res = await env.d1.prepare("SELECT 'Hello, World!' AS message").first<{ message: string }>();
        return res.message;
    }
}

Unlike Models, which require all attributes to be SQL columns, KV keys, or R2 objects, Services allow attributes to be any arbitrary value, searching for them in the dependency injection context. This means you can easily inject custom services, utilities, or configurations into your Service methods as needed.

@Service
export class HelloWorldService {

    env: Env;
    request: Request;
    foo: string;

    init(): void {
        this.foo = "bar";
    }

    @GET
    async hello(): Promise<string> {
        console.log("Request URL:", this.request.url);
        const res = await this.env.d1.prepare("SELECT 'Hello, World!' AS message").first<{ message: string }>();
        return res.message + " and foo is " + this.foo;
    }
}

Services as Dependencies

Services can also be injected into other Services. They cannot be circularly dependent, but otherwise, you can freely compose Services together.

@Service
export class GreetingService {
    greet(name: string): string {
        return `Hello, ${name}!`;
    }
}

@Service
export class HelloWorldService {
    greetingService: GreetingService;

    @GET
    hello(name: string): string {
        return this.greetingService.greet(name);
    }
}

Middleware

Middleware functions are events called in between states of the Cloesce Router processing pipeline. They can be used to modify requests, exit early, or perform actions before or after certain operations.

It is important to note that the Cloesce client expects results to come exactly as they have been described in API methods. Therefore, middleware should not modify the structure of a response or change the expected output of an API method unless you are fully aware of the implications.

Custom Main Entrypoint

The most basic form of middleware is a custom main entrypoint function, which will be called for every request to your Cloesce application.

Cloesce will search your project for an exported main entrypoint. If it doesn’t appear, a default main will be generated that simply initializes the Cloesce application. The main entrypoint allows you to intercept a request before it reaches the Cloesce Router, and handle the output of the Cloesce Router as you see fit.

Below is an example of using the main entrypoint to attach CORS headers to every response:

import { CloesceApp } from "cloesce/backend";
export default async function main(
    request: Request,
    env: Env,
    app: CloesceApp,
    _ctx: ExecutionContext): Promise<Response> {
    // preflight
    if (request.method === "OPTIONS") {
        return HttpResult.ok(200, undefined, {
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
            "Access-Control-Allow-Headers": "Content-Type, Authorization",
        }).toResponse();
    }

    // Run Cloesce router
    const result = await app.run(request, env);

    // attach CORS headers
    result.headers.set("Access-Control-Allow-Origin", "*");
    result.headers.set(
        "Access-Control-Allow-Methods",
        "GET, POST, PUT, DELETE, OPTIONS"
    );
    result.headers.set(
        "Access-Control-Allow-Headers",
        "Content-Type, Authorization"
    );

    return result;
}

Note: The Cloesce Router will never throw an unhandled exception. All errors are converted into HttpResult responses. Therefore, there is no need to wrap app.run in a try/catch block. 500 errors are logged by default.

Middleware Hooks

Alpha Note: Middleware hooks are likely to change significantly before a stable release.

Middleware hooks can be registered to run at specific points in the Cloesce Router processing pipeline. The available middleware hooks are:

HookDescription
onRouteCalled when a request hits a valid route with the correct HTTP method. Service initialization occurs directly after this point, thus services will not be available.
onNamespaceCalled when a request hits a specific namespace (Model or Service). Occurs after service initialization but before request body validation.
onMethodCalled when a request is about to invoke a specific method. Occurs after request body validation but before hydration and method execution.

Note: Many hooks can be registered. Hooks are called in the order they are registered, per hook.

Each hook has access to the dependency injection container for the current request, allowing you to modify it as needed.


export class InjectedThing {
    value: string;
}

export default async function main(
    request: Request,
    env: Env,
    app: CloesceApp,
    _ctx: ExecutionContext): Promise<Response> {
        app.onNamespace(Foo, (di) => {
            di.set(InjectedThing, {
                value: "hello world",
            });
        });

        app.onMethod(Foo, "blockedMethod", (_di) => {
            return HttpResult.fail(401, "Blocked method");
        });

        return await app.run(request, env);
}

Middleware is capable of short-circuiting the request processing by returning an HttpResult directly. This is useful for implementing features like authentication. Middleware can also modify the dependency injection container for the current request, allowing you to inject custom services or data.

Alpha Note: Middleware can only inject classes into the DI container at this time. Injecting primitive values (strings, numbers, etc) is not yet supported, but a solution is planned for a future release.

Testing

Cloesce Models and Services all live as their own isolated units with no inherent connection to an incoming Worker request, making them easy to unit test.

To write tests for Cloesce that utilize any ORM features, ensure you have:

  • Have ran npx cloesce compile to generate the necessary files
  • Run migrations for the Models being tested
  • Invoke CloesceApp.init to initialize the Cloesce runtime
import { CloesceApp } from "cloesce/backend";
import { cidl, constructorRegistry } from "@generated/workers";

// Cloesce must be initialized before utilizing any ORM features.
// It takes in the generated Cloesce Interface Definition Language (CIDL)
// and the generated constructor registry. Both may be imported from
// "@generated/workers" as shown above.
beforeAll(() => CloesceApp.init(cidl as any, constructorRegistry));

Cloesce needs only the CIDL (generated interface definition) and Constructor Registry (linked Model, Service and Plain Old Object exports) to function be used in tests.

Since Models rely on Cloudflare Workers bindings (D1, KV, R2, etc), you will need to mock these bindings in your test environment. The best choice for this is Miniflare, which provides an in memory implementation of Cloudflare Workers runtime and bindings.

A basic Miniflare setup is shown in the template project which can be installed with:

$ npx create-cloesce my-cloesce-app

Compiler Reference

Alpha Note: As Cloesce continues to evolve, the compiler architecture and features may change. This chapter will be updated accordingly to reflect the latest design and implementation details.

This chapter provides a detailed reference for the Cloesce compiler, including its design principles, architecture and key components.

Future Vision

Cloesce is an ambitious project that aims to create a simple but powerful paradigm for building full stack applications. Several goals drive the future development of Cloesce, shaping its design and evolution. Although still in its early stages, these goals provide a clear roadmap for its growth.

Language Agnosticism

A central concept of Cloesce’s vision is full language agnosticism. Modern web development forces teams to navigate a maze of parallel ecosystems, each with its own web servers, dependency injection patterns, ORMs, migration tools, validation libraries, and routing conventions. These systems all solve the same problems, yet they do so with incompatible syntax and assumptions. Cloesce aims to collapse this redundancy by defining a single architecture that can be compiled into any target language, allowing developers to choose their preferred runtime without sacrificing consistency.

On the client side, this goal is well within reach: any language can consume REST APIs, making generated client code naturally portable. The server side, however, presents a deeper challenge. To move toward true language independence, Cloesce’s core is implemented in Rust and compiled to WebAssembly, enabling the possibility of targeting multiple server environments in the future.

The most significant obstacle today is the Cloudflare Workers platform, which treats only JavaScript and TypeScript as first-class citizens. Encouragingly, Cloudflare’s ongoing effort to support Rust based Workers through WebAssembly is now in beta and shows strong potential. As this matures, Cloesce will be able to target Rust and possibly other compiled languages without compromising its architecture.

Equally important is the evolution of WASI. With the upcoming WASI Preview 3 introducing a native async runtime, WebAssembly is rapidly becoming a viable foundation for general purpose server development. This progress directly expands the horizons of what Cloesce can support in the future, and also allows Cloesce to move the entirety of its runtime into WebAssembly, further decoupling it from any single language or platform.

Support the full set of Cloudflare Workers features

Cloesce should be the best way to develop with Cloudflare. To achieve this, developers cannot be limited in leveraging the capabilities of the Workers platform. Future versions of Cloesce will aim to enhance the full range of Workers features, including:

  • Durable Objects and Web Socket API generation
  • Native solution for D1 sharding
  • Worker to Worker communication
  • Hyperdrive + PostgreSQL support
  • … and more as Cloudflare continues to expand the Workers platform.

Designed for AI Integration

With Cloesce, you write less code. Significantly less. Furthermore, the code you do write is at a high level of abstraction, focusing on only data models and business logic. This design makes Cloesce an ideal candidate for AI assisted development. Not only would creating a project require a fraction of the tokens, but the high level nature of the code means that AI can more easily understand the intent and structure of the application.

By its stable release, simply asking an AI agent to “Create Twitter with Cloesce” should yield a complete, functional application ready to deploy to Cloudflare Worker in a record low token count.

Architecture Overview

We can break down the Cloesce architecture into three components, each with their own subdomains: the Frontend, Generator and the Migrations Engine.

Frontend

The Frontend layer of Cloesce encompasses all components responsible for interfacing with the user’s project and extracting high level language into the Cloesce Interface Definition Language (CIDL). It serves as the both the entrypoint for compilation and the runtime environment for generated code.

IDL Extraction

A key design choice when building Cloesce was to not force users to write their Models in a separate IDL or DSL as seen in tools like gRPC and Prisma.

Instead, we opted to have the Cloesce compiler utilize the source languages AST to extract Model definitions directly from the user’s source code. This allows users to define their Models using familiar syntax and semantics, while still benefiting from the powerful features of Cloesce. Of course, this means we must write an extractor for each supported frontend language. Currently, the only supported language is TypeScript.

The Extractor portion of the compiler is responsible for scanning the user’s source files (marked with .cloesce.<lang>) and identifying Model definitions through stub preprocessor directives. Extraction does not perform any semantic analysis, it simply extracts the Model definitions and their properties into an intermediate representation (the CIDL).

The CIDL describes a full stack project. Every Model definition, Service, API endpoint and Wrangler binding is stored in the CIDL. Currently, this representation is serialized as JSON, but in the future we may explore other formats such as Protocol Buffers or FlatBuffers for better performance and extensibility.

At the end of CIDL Extraction, a cidl.pre.json file is produced to semantically validated by the Generator.

Runtime

Beyond extraction, the Frontend layer also includes the runtime environment for workers. Originally, we considered generating entirely standalone code for the Workers, but a shift to instead interpret the CIDL at runtime as if it was the program text allowed us to greatly reduce the amount of generated code, add tests and improve maintainability. Each supported frontend language has its own runtime implementation that can interpret the CIDL using simple state machine logic. Moving as much of the runtime logic into WebAssembly as possible helps portability to other languages in the future.

The runtime consists of two components: the Router and the ORM. The Router is currently written entirely in TypeScript, while the ORM compiles to WebAssembly from Rust.

Depending on the context, the CIDL may be referred to as the “Abstract Syntax Tree”, the “Cloesce Interface Definition Language”, or during the runtime just “metadata” when a particular node is being referenced. All of these labels are accurate– it’s a versatile structure!

Router

The Cloesce Router is responsible for handling incoming HTTP requests, matching them to an API endpoint defined in the CIDL, validating request parameters and body, hydrating data from the ORM and dispatching to a user defined method on a Model or Service. Along the way, the Router calls middleware functions defined in the CIDL. Although each middleware function can produce undefined behavior, each state in the Router is well defined and can produce only a corresponding failure state or success state. This makes reasoning about the Router’s behavior straightforward.

ORM

The Cloesce ORM is responsible for fetching and updating data stored in SQL, KV and R2 according to the Model definitions and Include Trees passed. The majority of the ORM is written in Rust, however some portions are written in TypeScript such as KV and R2 hydration logic.

Generator

After being passed the pre.cidl.json file from the Frontend, the Generator performs semantic analysis on the CIDL and Wrangler configuration to ensure that the project is valid. This includes checking for translatable SQLite types, sorting Models and Services topologically, validating API endpoints and more. If any errors are found, the Generator will output them to the user and halt compilation.

After semantic analysis is complete, the Generator produces the final cidl.json file which is then used to generate code for the worker runtime and client code generation. The generator will augment the CIDL with additional information like hashes for migrations and CRUD API endpoints for Models and Services.

To make the Generator easily available to the frontend, it is written entirely in Rust and compiled to WebAssembly. This allows frontend languages to easily call into the Generator without needing to write language specific bindings. In the future, we may explore compiling native binaries for better performance, but WASM reduces the complexity of distributing multiple binaries for different platforms.

Migrations Engine

After a successful compilation, the Migrations Engine is used to generate database migrations from changes in the Model definitions, utilizing the Merkle-Tree hashes the Generator added to the CIDL.

The engine can sometimes encounter problems it does not know the solution to, such as when a Model is renamed. In these cases, the engine will prompt the user with options on how to proceed (such as generating a rename migration or creating a new Model). This interactive process ensures that the generated migrations align with the user’s intent.

The Migrations Engine outputs SQLite files intended to be applied to D1 databases using the Wrangler CLI. It is written entirely in Rust and comes with the Generator as a single WebAssembly module for easy distribution.