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

Warning

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

Cloesce converts class definitions into a full stack Cloudflare application.

Inspired by

Cloesce is not just an ORM, migration 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.

@Crud("GET", "SAVE", "LIST")
@Model("db")
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()
    hello(): User {
        return this;
    }
}
# Coming in a later release!
// Coming in a later release!

How easy 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. Many core concepts of Cloesce come directly from Coalesce (Cloesce = Cloudflare + Coalesce).

LLMs

Interact with this documentation with an LLM by utilizing the llms-full.txt found here. Download from the terminal using curl:

curl https://cloesce.pages.dev/llms-full.txt -o llms-full.txt

TypeDoc

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

Getting Started

Tip

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.

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

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

Installation

Note

Cloesce supports only TypeScript to TypeScript compilation as of Alpha v0.2.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, 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 (not necessary for local development)
  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 created for you.

├── src/
│   ├── data/           # Example Cloesce Models
│   └── web/            # Frontend web assets
├── test/               # Unit tests for example Models
├── migrations/         # Database migration files
├── cloesce.config.ts # Cloesce 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 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, tailored 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.jsonc 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("db")
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;

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

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

    title: string;
    description: string;

    weatherEntries: Weather[];
}

The Weather Model consists of:

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

The WeatherReport Model consists of:

FeatureType / DescriptionSource / Layer
idPrimary KeyD1
titleScalar columnD1
summaryScalar columnD1
weatherEntriesOne-to-Many relationship with WeatherD1
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.ts file in your project root, which contains configuration settings for Cloesce. 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 runtime.

  • 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.jsonc.

Generating Migrations

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

npx cloesce migrate <d1-binding> <migration-name>

# Or to generate a migration for all D1 bindings:
npx cloesce migrate --all <migration-name>

This command compares your current Cloesce Models against the last applied migration and generates a new migration file in the migrations/<d1-binding> 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 <d1-binding-name>

Running

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

npx wrangler dev --port <port-number>

Deploying

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

    Ensure your cloesce.config.ts file is correctly configured for production, including the production Worker URL.

  2. Configure Wrangler bindings

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

    {
       "r2_buckets": [
          {
             "binding": "bucket",
             "bucket_name": "xxxxxxxx"
          }
       ],
    }
    
  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. Surprisingly, 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

Note

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, i.e., snake case, camel case, etc). Cloesce will automatically treat a property named id as the primary key.

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

During Extraction, Cloesce 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("db")
export class User {
    @PrimaryKey
    id: Integer;

    name: string;
}

The above code defines a Model “User” stored in the D1 database db, 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.

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

Fluent API

Some column configurations cannot be cleanly expressed through TypeScript decorators alone. For these cases, Cloesce provides a Fluent API that can called in cloesce.config.ts to further customize the D1 schema. For example, to make a column unique:

import { defineConfig } from "cloesce/config";
import { Weather } from "./src/data/models.cloesce";

const config = defineConfig({
    // ...
});

config.model(Weather, builder => {
    builder.unique("dateTime", "location");
});

Additionally, Cloesce exposes a method to modify the AST after extraction:

config.rawAst((ast) => {
    // modify the raw AST here
});

Migrating the Database

Important

Any change in a D1 backed Model definition (adding, removing, or modifying properties; renaming Models) requires a new migration to be created.

The migration command will generate a new migration file in the migrations/ directory.

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 <d1-binding> <migration name>

Finally, these generated migrations must be applied to the actual D1 database using the Wrangler CLI:

npx wrangler d1 migrations apply <d1-binding>

Navigation Properties

In the previous section, we built a basic D1 backed 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

Note

A Model can only have a foreign key to another Model if it is:

  1. D1 backed
  2. Part of the same database as the Model it references (lifted in future releases!)

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("db")
export class Dog {
    id: Integer;
}

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

    @ForeignKey<Dog>(d => d.id)
    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 } from "cloesce/backend";

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

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

    dogId: Integer;
    dog: Dog | undefined;
}

In this example, Cloesce infers that dog is a navigation property to Dog, with dogId as the foreign key. This allows us to access the associated Dog instance directly from a Person instance.

Cloesce has a simple inference engine that finds navigation properties, then searches for a property in the Model with the name <navPropName><primaryKeyName> (in any casing) to use as the foreign key.

This relationship can be explicitly expressed using the Fluent API in cloesce.config.ts:

config.model(Person, builder => {
    builder

    // Property "dogId" is a foreign key referencing the model Dog,
    // using Dog's primary key "id"
    .foreignKey("dogId")
        .references(Dog, "id")
    
    // Property "dog" is one to one referencing the model Dog,
    // using the foreign key "dogId"
    .oneToOne("dog")
        .references(Dog, "dogId");
});

One to Many

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

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

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

    ownerId: Integer;
    owner: Person | undefined;
}

@Model("db")
export class Person {
    id: Integer;
    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.

Cloesce can infer this relationship by finding the first property in Dog that references Person as a foreign key, and using that as the basis for the one to many relationship. If many properties reference Person, it will need to be explicitly stated in the Fluent API:

config.model(Person, builder => {
    builder
    .oneToMany("dogs")
        .references(Dog, "ownerId");
    .oneToMany("otherDogs")
        .references(Dog, "otherOwnerId");
});

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("db")
export class Student {
    id: Integer;

    courses: Course[];
}

@Model("db")
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.

Composite Keys

A Model can have a composite primary key by using the @PrimaryKey decorator on multiple properties. A primary key may also be a foreign key.

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

@Model("db")
class Enrollment {
    @PrimaryKey
    @ForeignKey<Student>(s => s.id)
    studentId: Integer;

    @PrimaryKey
    courseId: Integer;

    student: Student | undefined;
    course: Course | undefined;
}

@Model("db")
class Student {
    id: Integer;
    enrollments: Enrollment[];
}

Here, Cloesce is able to infer that Student has many Enrollments through enrollments because the Enrollment Model has a foreign key to Student.

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

@Model("db")
class Person {
    @PrimaryKey
    firstName: string;

    @PrimaryKey
    lastName: string;

    dog: Dog | undefined;
}

@Model("db")
class Dog {
    id: Integer;

    ownerFirstName: string;
    ownerLastName: string;
    owner: Person | undefined;
}

Because a navigation property owner is defined on Dog, Cloesce can infer that ownerFirstName and ownerLastName together form a composite foreign key to Person.

Without a navigation property, Cloesce Models can only decorate a single foreign key, so the Fluent API must be used to explicitly define the composite foreign key:

config.model(Dog, builder => {
    builder
        .foreignKey("ownerFirstName", "ownerLastName")
        .references(Person, "firstName", "lastName");
});

Data Sources

If you fetch a Model you may notice that Cloesce will leave undefined or empty arrays in deeply nested composition with other Models. This is intentional, and is handled by Data Sources.

What are Data Sources?

Important

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

Data Sources are Cloesce’s response to the overfetching and recursive relationship challenges when modeling relational databases 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("db")
export class Dog {
    id: Integer;

    ownerId: Integer;
    owner: Person | undefined;
}

@Model("db")
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, resulting in an infinite loop of data retrieval.

Data Sources, through their includeTree configuration, 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 includeTree, 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.

Default Data Source

Cloesce will create a default Data Source for each model called “default”. This Data Source will include all KV, R2, and D1 properties, but will avoid both circular references and nested relationships in arrays.

For example, in the Person and Dog Models above, the default Data Source for Person would be:

{
    includeTree: {
        dogs: {
            // No more includes, preventing infinite recursion
        }
    }
}

The default Data Source for Dog would be:

{
    includeTree: {
        owner: {
            dogs: {
                // No more includes, preventing infinite recursion
            }
        }
    }
}

The default Data Source can be generated on demand using the Cloesce ORM (see Cloesce ORM chapter for more details), or it can be overridden with a custom Data Source definition (see next section).

Custom Data Sources

In addition to the default Data Source, you can define custom Data Sources on your Models, or even override the default Data Source. Each Data Source you define on a Model will be accessible by the client for querying that Model, and you can have as many Data Sources as you want.

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

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

    ownerId: Integer;
    owner: Person | undefined;
}

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

    static readonly withDogsOwnersDogs: DataSource<Person> = {
        includeTree: {
            dogs: {
                owner: {
                    dogs: {
                        // ... could keep going!
                    }
                }
            }
        }
    };

    static readonly default: DataSource<Person> = {
        includeTree: {}
    };
}

In this example, we defined a custom Data Source called withDogsOwnersDogs on the Person Model. This Data Source specifies that when fetching a Person, we want to include their dogs, and for each Dog, we want to include their owner, and for each owner, we want to include their dogs again. This allows for a much deeper fetch than the default Data Source, but it is still explicitly defined to prevent infinite recursion.

We also overrode the default Data Source for Person to be an empty include tree, meaning that by default, fetching a Person will not include any related Navigation Properties unless some other Data Source is specified in the query.

Custom Data Source Queries

On top of creating the structure of hydrated data, Data Sources are also responsible for the underlying SQL queries to fetch that data. Each Data Source comes with two default implementations for the methods: get and list.

get is responsible for fetching a single instance of the Model, while list is responsible for fetching multiple instances. get can take only the primary key(s) as arguments, while list can take lastSeen, limit and offset arguments for pagination.

Each method accepts an argument joined which generates SELECT * FROM ... JOIN ... query based off the includeTree structure of the Data Source.


// Cloesce will generate a data source like this by default.
const customDs: DataSource<User> = {
    includeTree: {
        dogs: {}
    },

    // NOTE: This is equivalent to the default `get` implementation
    get: (joined) => `
        WITH joined AS (${joined()})
        SELECT * FROM joined WHERE id = ?
    `,

    // NOTE: This is equivalent to the default `list` implementation
    list: (joined) => `
        WITH joined AS (${joined()})
        SELECT * FROM joined WHERE id > ? ORDER BY id LIMIT ?
    `,

    // Array of parameters available for the list method. Also defines
    // the order of those parameters. `lastSeen` can be multiple primary keys 
    // for composite key models, defined in the same order as the primary keys.
    listParams: ["LastSeen", "Limit"] 
}

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

    static readonly default: DataSource<Person> = customDs;
}

See the Cloesce ORM chapter and Model Methods chapter for more details on how to use custom Data Sources in queries.

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

Important

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.

Important

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

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.

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 } from "cloesce/backend";

@Model() // no database specified
export class Settings {
    @KeyParam
    settingsId: string;

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

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

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.

Data Sources can be used with KV Models as well to specify which properties to include when fetching data.

Defining a Model with R2

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.

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() // no database specified
export class MediaFile {
    @KeyParam
    fileName: string;

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

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.

Data Sources can also be used with R2 Models to specify which properties to include when fetching data.

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, DataSource } from "cloesce/backend";

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

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

@Model("db")
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;
}

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.

Furthermore, 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, but also 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

Warning

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

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

A static method simply exists on the same namespace as the Model class. An instance method exists on an actual instance of the Model.

Methods must be decorated with an HTTP Verb such as @Get, @Post, etc. to be exposed as API endpoints.

Each endpoint may take a Data Source as an optional query parameter to specify which related data to include in the response. It may also define a Data Source inline if it should not be shared with the client.

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

@Model("db")
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.

CRUD Methods

Important

Delete is not yet supported as a generated CRUD method in alpha v0.2.0, but it is planned for a future release.

Note

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

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, Crud } from "cloesce/backend";

@Crud("GET", "SAVE", "LIST")
@Model("db")
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 DataSource in the request to specify which navigation properties to include in the response. This defaults to the default Data Source.

Private Data Sources

A Model may be composed of several other Models, KV entries and R2 objects, but one method may only need to fetch a subset of that data. In this case, you can define a Data Source for that specific method:

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

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

    @Get({ includeTree: {} }) // can also define as a `const` outside the method and reuse it if needed
    getComputedField(): string {
        return `User: ${this.name}`;
    }
}

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

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.

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("db")
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.");
    }
}

HttpResult

Every method response in Cloesce is converted to an 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("db")
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

Caution

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

During the hydration step of the Cloesce runtime, all of a Model’s data is fetched from its 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.

This functionality is exposed through the Orm class in the cloesce/backend package.

Data Sources

A DataSource<T> describes how a Model should be fetched and hydrated. It pairs an optional IncludeTree (which relationships to join) with optional custom SQL for get and list queries.

interface DataSource<T> {
    includeTree?: IncludeTree<T>;
    get?: (joined: (from?: string) => string) => string;
    list?: (joined: (from?: string) => string) => string;
    listParams?: ("LastSeen" | "Limit" | "Offset")[];
}
  • includeTree — which relationships to include (KV, R2, 1:1, 1:M, M:M).
  • get — custom SQL for orm.get. Receives a helper that generates the joined SELECT. Primary key columns are always bound in order via ?.
  • list — custom SQL for orm.list. Receives the same helper. Bind parameters are declared in listParams.
  • listParams — which parameters to bind when executing the custom list query. Defaults to empty.

All ORM methods that accept an include accept either a DataSource<T> or a plain IncludeTree<T> interchangeably.

Default Data Source

Cloesce generates a default DataSource for every Model at compile time. It includes all near relationships (KV, R2, 1:1) and the shallow side of 1:M and M:M relationships. This is used whenever no explicit Data Source is provided to an ORM method or instance method.

You can access it at runtime with:

const defaultDs = Orm.defaultDataSource(User);

Defining a static readonly default property on your Model that is a DataSource<T> with an includeTree overrides the compiler-generated default.

Getting and Listing Models

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

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

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

get requires the primary key via primaryKey. For composite primary keys, supply all key columns: primaryKey: { professorId: 1, courseId: 2 }. Any keyParams needed to construct KV or R2 keys are passed alongside it. Returns null when no matching row is found.

list takes an optional args object and cannot be used with Models that require key parameters for KV or R2 properties. Use prefix queries for those instead.

Pagination

orm.list uses seek-based pagination by default. Pass lastSeen, limit, and offset to page through results:

const page1 = await orm.list(User, { limit: 50 });

const page2 = await orm.list(User, {
    lastSeen: { id: page1[page1.length - 1].id },
    limit: 50
});

The default query is WHERE (primaryKey) > (lastSeen) ORDER BY primaryKey LIMIT ?, which stays consistent under concurrent inserts. For LIMIT/OFFSET pagination or custom ordering, provide a list function on a custom Data Source.

Paginated KV and R2 Fields

KV and R2 list fields are declared with Paginated<T>:

@Model("db")
class User {
    id: Integer;

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

    @R2("files/{id}", bucket)
    files: Paginated<R2ObjectBody>;
}
interface Paginated<T> {
    results: T[];    // first page, up to 1,000 entries
    cursor: string | null;
    complete: boolean;
}

To retrieve the next page, use the cursor from the previous result with a custom method on your Model.

Select, Map and Hydrate

When you need filtering, ordering, or aggregation beyond what get and list provide, write the SQL directly. The ORM gives you three methods to bridge raw SQL results back to hydrated Model instances.

Orm.select generates the appropriate SELECT with LEFT JOINs and column aliases for a given Data Source. For example, given:

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

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

Orm.select(Boss, { include: Boss.withAll }) produces:

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"

The aliased columns make it straightforward to filter on nested relationships via a CTE:

const query = `
    WITH BossCte AS (
        ${Orm.select(Boss, { include: Boss.withAll })}
    )
    SELECT * FROM BossCte
    WHERE [persons.dogs.id] = 5
      AND [persons.cats.id] = 10
      AND [persons.id] = 15
`;

An optional from string wraps a subquery as the base table:

Orm.select(Boss, {
    from: "SELECT * FROM Boss WHERE name = 'Alice'",
    include: Boss.withAll
});

Pass the D1 results to Orm.map to reconstruct the object graph:

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

Then orm.hydrate fetches any KV and R2 properties and returns fully populated Model instances:

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

Note

Orm.map requires results in the exact aliased format produced by Orm.select. Mixing in results from other queries may fail.

Saving a Model

orm.upsert handles both creating and updating a Model, including nested D1 and KV relationships. R2 properties are not supported; large binary data 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, omit to auto-increment
    name: "New User",
    friends: [
        { name: "Friend 1" },
        { id: 1, name: "My Best Friend" } // update existing
    ]
}, User.withFriends);

The returned instance has all primary keys assigned and any navigation properties specified by the third argument (DataSource<T> or IncludeTree<T>) populated.

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 configuration 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.jsonc or wrangler.toml file is generated during compilation based on this class. Configure your preference in the cloesce.config.ts file:

import { defineConfig } from "cloesce/config";

const config = defineConfig({
    // ...
    wranglerConfigFormat: "jsonc", // or "toml"
});

Currently, only D1 databases, R2 buckets, KV namespaces, and string environment variables are supported.

Cloesce will not overwrite an existing wrangler 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.jsonc 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",
            "migrations_dir": "./migrations/db"
        }
    ],
    "r2_buckets": [
        {
            "binding": "bucket",
            "bucket_name": "replace-with-r2-bucket-name"
        }
    ],
    "kv_namespaces": [
        {
            "binding": "kv",
            "namespace_id": "replace_with_kv_namespace_id"
        }
    ],
    "vars": {
        "someVariable": "default_string"
    }
}

Services

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.

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

Services are another core concept that allows 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 related, complex functionality together and can be injected into other parts of your application using Cloesce’s dependency injection system.

Hello World Service

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

import { Service, Get, HttpResult } 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 between states of the Cloesce Router processing pipeline. They can be used to modify requests, exit early, or perform actions before and after operations.

It is important to note that the Cloesce client expects results to come exactly as 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

Tip

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. HTTP 500 errors are logged by default.

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 none is found, 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;
}

Middleware Hooks

Warning

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

Warning

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

Tip

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

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, therefore 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.

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.

Testing

Cloesce Models and Services live as isolated units with no inherent connection to an incoming Worker request, making them ideal for unit testing.

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

  • Ran npx cloesce compile to generate the necessary files
  • Applied migrations for the Models being tested
  • Invoked 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 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, "http://localhost:8787"));

Cloesce needs only the CIDL (generated interface definition) and Constructor Registry (linked Model, Service and Plain Old Object exports) to be used in tests. This means you can write tests for your Models and Services without needing to run a full Cloudflare Worker environment.

ORM methods rely on Cloudflare Workers bindings (D1, KV, R2, etc.), so 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 included in the template project which can be installed with:

npx create-cloesce my-cloesce-app

Compiler Reference

Important

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

This chapter provides a detailed reference for the Cloesce, 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 Cloesce’s future development, shaping its design and evolution. Although still in its early stages, these goals provide a clear roadmap for its growth.

Language Agnosticism

A central tenant 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
  • A 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 operates 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 Workers 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 both the entrypoint for compilation and the runtime environment for generated code.

IDL Extraction

A key design choice when building Cloesce was not to 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 Cloesce 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 Cloesce 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 be 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 shifting to interpret the CIDL at runtime 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.

Tip

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.