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

The Cloesce Schema Language

Warning

Cloesce is under active development, expanding its feature set as it pushes toward full Cloudflare support across any language. The syntax and features described here are subject to change as the project evolves.

Cloesce is a schema language (or IDL) that describes a full stack application built on Cloudflare’s edge ecosystem. It provides a single source of truth for your application, with a single language to define:

FeatureSupport
ORM
RPC stubs
Middleware
IaC
SQL Migrations
Runtime Validation

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

VS Code Extension

A basic language highlighting extension for Cloesce is available in the VS Code marketplace. In the future, this extension will also include a full LSP server.

More editor integrations are planned for the future (and you can always contribute your own!). If you’re interested in contributing an editor extension, reach out in the Discord server.

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 compilation as of v0.3.0. Support for additional languages will be added in future releases.

Installing the Compiler

Linux and macOS

curl -fsSL https://cloesce.pages.dev/install.sh | sh

Windows (PowerShell)

irm https://cloesce.pages.dev/install.ps1 | iex

Then verify the installation:

cloesce version

Starting a New Project

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, a basic schema, 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/
│   ├── api/            # API route handlers
│   ├── web/            # Frontend web assets
│   └── schema/
│       └── schema.clo  # Cloesce schema definition
├── test/               # Unit tests for example Models
├── migrations/         # Database migration files
├── cloesce.jsonc       # 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.

Schema

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.

env {
    d1 { db }
    r2 { bucket }
}

In the above Cloesce snippet, we define a Wrangler Environment with a D1 database binding named db and an R2 bucket named bucket.

After compilation, Cloesce generates a wrangler.jsonc (or wrangler.toml if configured) with the appropriate bindings for your application based on the env block in your schema. Cloesce does not handle provisioning of these resources, so you must assign each resources id to an existing resource in your Cloudflare account.

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

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 schema/schema.clo you will find two example Models, Weather and WeatherReport.

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}.jpgR2
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
Weather Code Snippet
[use db]
[use list, save, get]
model WeatherReport {
    primary {
        id: int
    }

    nav (Weather::weatherReportId) {
        weatherEntries
    }

    title: string
    description: string
}

WeatherReport Code Snippet
[use db]
[use get, list, save]
model Weather {
    primary {
        id: int
    }

    foreign (WeatherReport::id) {
        weatherReportId
        nav { weatherReport }
    }
    
    r2 (bucket, "weather/photos/{id}.jpg") {
        photo
    }

    dateTime: date
    location: string
    temperature: int
    condition: string
}

api Weather {
    post uploadPhoto(self, e: env, s: stream) -> void
    get downloadPhoto([source R2Only] self) -> stream
}

source R2Only for Weather {
    include { photo }
}

Read more about how Models work in the Models chapter.

Backend Implementation

In src/api/main.ts, you will find all of the TypeScript API route handlers for the example application. These handlers extend generated API interfaces, and must be explicitly registered during application initialization.

While Cloesce has a default Workers entrypoint in the generated backend code, almost every application will require custom API route handlers to implement business logic that cannot be expressed in the schema.

export const Weather = clo.Weather.impl({
    async uploadPhoto(self, e, s: CfReadableStream) {
        const key = this.Key.photo(self.id);
        await e.bucket.put(key, s);
    },

    downloadPhoto(self) {
        if (!self.photo) {
            return HttpResult.fail(404, "Photo not found");
        }
        return HttpResult.ok(200, self.photo.body);
    }
});

export default {
    async fetch(request: Request, env: clo.Env): Promise<Response> {
        const app = (await clo.cloesce())
            .register(Weather);

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

Building and Migrating

Building a Cloesce project consists of three steps

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

Cloesce Config

A cloesce config may be defined in a cloesce.jsonc file in your project root. This file is used to configure various aspects of the Cloesce compiler and generated code, such as source paths for your schema, the output directory for generated files, and the format of the generated Wrangler configuration file.

{
    "src_paths": ["./src/schema"],
    "workers_url": "http://localhost:5000/api",
    "wrangler_config_format": "jsonc" // or "toml"
}

If you have multiple environments (e.g., staging, tests, production), you can define multiple config files by prefixing the name: staging.cloesce.jsonc, production.cloesce.jsonc, etc. Then, specify which environment to use when running the CLI command:

cloesce --env staging ...

Compiling

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

cloesce compile

This command looks for a cloesce.jsonc file in your current directory, which contains configuration settings for Cloesce. If the file is not found, or settings are omitted, default values will be used. Because cloesce.jsonc is JSONC, it provides static configuration rather than executable code. For environment-specific settings, use separate config files such as staging.cloesce.jsonc or production.cloesce.jsonc and select them with cloesce --env <name> ...; for values that need to vary at invocation time, prefer supported CLI flags in your build or deployment scripts.

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

FileDescription
cidl.jsonThe Cloesce Interface Definition Language AST exported to JSON. This file is used internally by Cloesce during migrations, and is utilized by the generated backend code as a source of truth for the structure of your Models and their linked features.
client.tsThe 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.
backend.tsThe generated Cloesce ORM and API stubs for your backend. All Cloesce features translate to a namespace or interface in this file.

Generating Migrations

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

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

# Or to generate a migration for all D1 bindings:
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.jsonc

    Ensure your cloesce.jsonc 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:

    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

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
  • API methods.

Basic D1 Backed Model

Cloudflare D1 is a serverless SQL database built on SQLite for Workers. Cloesce provides first class support for D1, allowing you to define Models backed by D1 tables with just a few lines of code.

Defining a Model

Note

In v0.3.0, all symbols within a file are globally scoped, so you can split your Models and APIs across multiple files as you see fit.

All Cloesce models are defined within a .clo or .cloesce file.

// my-model.clo
[use db]
model User {
    primary {
        id: int
    }

    name: string
}

The above code defines a Model “User” stored in the D1 database db, with several properties:

PropertyDescription
UserA table backed by the D1 database db
idInteger property decorated scoped as a 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
intINTEGERRepresents an integer value
stringTEXTRepresents a string value
boolINTEGER0 for false, 1 for true
dateTEXTStored in ISO 8601 format
doubleREALRepresents a floating-point number
blobBLOBRepresents binary data

All of these types by themselves are NOT NULL by default. To make a property nullable, you may wrap it in an Option generic:

model User {
    optionalField: Option<string>
}

Notably, an int 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

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:

cloesce compile # load the latest Model definitions
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 Fields

In the previous section, we built a basic D1 backed Model with scalar fields. However, when utilizing relational databases like Cloudflare D1, you often need more complex relationships between tables.

Navigation Fields 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 Fields, it’s essential to understand their source: foreign keys. Foreign keys are scalar fields that reference the Primary Key of another Model, establishing a relationship (e.g., one-to-one, one-to-many) between the two Models.

The foreign block in Cloesce directly translates to a SQLite FOREIGN KEY constraint 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.

[use db]
model Dog {
    primary {
        id: int
    }

    name: string
}

[use db]
model Person {
    primary {
        id: int
    }

    foreign (Dog::id) {
        dogId
    }
}

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.

Optional Foreign Key

Cloesce does not allow circular foreign key relationships and will throw an error if it detects one at compile time. However, if you need to model such a relationship, you can make one of the foreign keys optional (nullable) and manage the relationship at the application level.

[use db]
model Person {
    primary {
        id: int
    }

    optional {
        foreign (Person::id) {
            parentId
        }
    }

    // or use infix, equivalent to the above:
    //
    // foreign (Person::id) optional {
    //     parentId
    // }
}

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 Fields. All navigation fields are D1 backed Models themselves, or arrays of D1 backed Models.

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

[use db]
model Dog {
    primary {
        id: int
    }

    name: string
}

[use db]
model Person {
    primary {
        id: int
    }

    foreign (Dog::id) {
        dogId
        nav { dog }
    }
}

In this example, we’ve added a navigation field dog to the foreign key block to Dog::id. During hydration of a Person instance, Cloesce will automatically populate the dog property with the corresponding Dog instance based on the foreign key relationship. Mythical!

One to Many

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

[use db]
model Dog {
    primary {
        id: int
    }

    name: string
    foreign (Person::id) {
        ownerId
    }
}

[use db]
model Person {
    primary {
        id: int
    }

    nav (Dog::ownerId) {
        dogs
    }
}

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.

Many to Many

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

[use db]
model Course {
    primary {
        id: int
    }

    name: string

    nav (Student::courses) {
        students
    }
}

[use db]
model Student {
    primary {
        id: int
    }

    name: string

    nav (Course::students) {
        courses
    }
}

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 listing multiple fields in a primary block. A primary key may also be a foreign key.

[use db]
model Enrollment {
    primary {
        foreign (Student::id) {
            studentId
            nav { student }
        }

        foreign (Course::id) {
            courseId
            nav { course }
        }
    }

    // or use infix, equivalent to the above:
    //
    // foreign (Student::id) primary {
    //     studentId
    //     nav { student }
    // }
    //
    // foreign (Course::id) primary {
    //     courseId
    //     nav { course }
    //
    // }
}

[use db]
model Student {
    primary {
        id: int
    }

    nav (Enrollment::studentId) {
        enrollments
    }
}

In this example, the Enrollment Model has a composite primary key consisting of studentId and courseId, which are also foreign keys to the Student and Course Models, respectively. The Student Model has a navigation property enrollments, which is an array of Enrollment instances representing all courses a student is enrolled in.

[use db]
model Person {
    primary {
        firstName: string
        lastName: string
    }
}

[use db]
model Dog {
    primary {
        id: int
    }

    foreign (Person::firstName, Person::lastName) {
        ownerFirstName
        ownerLastName
        nav { owner }
    }
}

In this example, the Dog Model has a composite foreign key consisting of ownerFirstName and ownerLastName, which reference the firstName and lastName fields of the Person Model, respectively. The foreign key block also includes a navigation property owner, which will be populated with the corresponding Person instance during hydration of a Dog instance.

Unique Constraints

Cloesce supports adding unique constraints to any column or foreign key. By default, a primary key is unique. Any field within a unique block is part of the same unique constraint.

[use db]
model User {
    primary {
        id: int
    }

    unique {
        email: string
    }

    // infix syntax also supported:
    foreign (Group::id) unique {
        groupId
    }

    unique {
        foreign (OtherModel::id) {
            otherId
        }

        foreign (AnotherModel::id) {
            anotherId
        }
    }
}

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, int, bool, etc.) are always included in query results. Include Trees are only necessary for Navigation Fields.

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?

[use db]
model Dog {
    primary {
        id: int
    }

    foreign (Person::id) {
        ownerId
        nav { owner }
    }
}

[use db]
model Person {
    primary {
        id: int
    }

    nav (Dog::ownerId) {
        dogs
    }
}

// => { 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 include block, allow developers to explicitly state which related Navigation Fields should be included in the query results, preventing overfetching. If a Navigation Field is not included in the include, it will remain undefined (for singular relationships) or an empty array (for collections).

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 include for Person would be:

include {
    dogs 
}

The default Data Source for Dog would be:

include {
    owner {
        dogs
    }
}

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.

[use db]
model Dog {
    primary {
        id: int
    }

    foreign (Person::id) {
        ownerId
        nav { owner }
    }
}

[use db]
model Person {
    primary {
        id: int
    }

    nav (Dog::ownerId) {
        dogs
    }
}

source WithDogsOwnersDogs for Person {
    include {
        dogs {
            owner {
                dogs {
                    // ... could keep going!
                }
            }
        }
    }
}

// Override the default to be empty
source Default for Person {
    include {}
}

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 Fields 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. Both queries may take any parameters. A default implementation is provided for both methods.

  • get defaults to fetching a single instance based on the primary key(s) of the Model.
  • list defaults to fetching multiple instances based on a cursor-based pagination strategy using the primary key(s) of the Model.

All parameters are properly bound to prevent SQL injection.

All queries have access to the $include parameter, which returns a SQL select statement that joins all the tables with respect to the Data Sources include tree. $include aliases all columns in a object oriented fashion.

source Custom for Person {
    include {
        dogs
    }

    sql get(id: int) {
        "
            WITH included AS ($include)
            SELECT * FROM included WHERE id = $id
        "
    }

    sql list(lastSeen: int, limit: int) {
        "
            WITH included AS ($include)
            SELECT * FROM included WHERE id > $lastSeen ORDER BY id LIMIT $limit
        "
    }
}

Every Data Source will be generated to the backend as a type safe query. By default, all Data Sources are exposed to the client for querying. However, should a Data Source be only intended for interal use, it can be marked with the internal tag:


[internal]
source MyInternal for Person {
    // ...
}

Data Sources are used extensively in the Cloesce ORM and Cloesce API layers to provide flexible and efficient data retrieval. By defining custom Data Sources, developers can optimize their queries to fetch only the necessary data, while still maintaining the ability to easily include related data when needed.

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

KV and R2 Fields

D1 is a powerful relational database solution, but is unsuited for storing large binary objects or frequently accessed non-relational data. 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

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

Note

KV fields 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 fields
  • Migrations
env {
    kv {
        myNamespace
    }
}

model Settings {
    keyfield {
        settingsId
    }

    kv(myNamespace, "settings/{settingsId}") {
        data: json
    }

    kv(myNamespace, "settings/") paginated {
        allSettings: json
    }
}

The above model has no D1 backing, and is purely stored in KV. A keyfield is a special type of field that is not stored anywhere, and is used only for constructing the key for KV entries. In this case, the settingsId field is used to construct the key for both the data and allSettings fields.

The data field is of type json, which means that the value stored in KV will be JSON. The allSettings field is marked as paginated, which means that it will fetch all entries in the settings/ prefix and return them as a paginated list (fetching 1000 entries at a time, the maximum allowed by Cloudflare).

Multiple KV and R2 fields can also be grouped into a paginated block, which marks all of them as paginated:

model Settings {
    keyfield {
        settingsId
    }

    kv(myNamespace, "settings/{settingsId}") {
        data: json
    }

    paginated {
        kv(myNamespace, "settings/") {
            allSettings: json
        }

        kv(myNamespace, "archive/") {
            archivedSettings: json
        }
    }

    // or, use infix syntax, equivalent to the above:
    //
    // kv(myNamespace, "settings/{settingsId}") paginated {
    //     allSettings: json
    // }
    //
    // kv(myNamespace, "archive/") paginated {
    //     archivedSettings: json
    // }
}

Data Source Include Trees can be used with any KV field as well to specify which fields to include when fetching data.

Defining a Model with R2

Note

R2 fields on a Model consider a missing object as a valid state, and will not return 404 errors. Instead, the field 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 Fields, 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 field 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.

env {
    r2 {
        myBucket
    }
}

model MediaFile {
    keyfield {
        fileName
    }

    r2(myBucket, "media/{fileName}.png") {
        file
    }

    r2(myBucket, "media/") paginated {
        allFiles
    }
}

In the MediaFile Model above, the fileName field is used as a keyfield to construct the key for the R2 object. The file field is of type R2Object, which means that it will return the metadata of the R2 object stored at the key media/{fileName}.png in the myBucket bucket. The allFiles field is marked as paginated, which means that it will fetch all objects in the media/ prefix and return their metadata as a paginated list.

Data Source Include Trees can also be used with R2 fields to specify which fields to include when fetching data.

Mixing Data Together

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

env {
    kv {
        myNamespace
    }

    r2 {
        myBucket
    }

    d1 {
        db
    }
}

[use db]
model DataCentaur {
    primary {
        id: int
    }

    r2(myBucket, "centaurPhotos/{id}.jpg") {
        photo
    }
}

[use db]
model DataChimera {
    primary {
        id: int
    }

    favoriteSettingsId: string

    foreign (DataCentaur::id) {
        dataCentaurId
        nav { dataCentaur }
    }

    kv(myNamespace, "settings/{favoriteSettingsId}") {
        settings: json
    }

    r2(myBucket, "media/{id}.png") {
        mediaFile
    }
}

In the DataChimera Model above, we have a mix of D1, KV, and R2 fields. The id field is stored in a D1 database, while the settings field is stored in KV and the mediaFile field 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 fields respectively.

Furthermore, using keyfields in a Model with D1 limits the capabilities of the ORM, discussed later in this chapter. It is recommended to avoid using keyfields in Models that use D1 unless you have a specific use case that requires it.

Model Methods

Note

Cloesce takes inspiration from RPC frameworks when generating backend and client code, but communicates entirely over HTTP.

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 v0.3.0 do not support complex types as parameters (such as other Models, arrays, etc). Only primitive types like string, int, double, bool are supported. This limitation will be lifted in future releases.

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

A static method exists on the namespace of the Model class itself and can be called without hydrating the Model.

An instance method exists on an actual instance of the Model and can access the fields of that instance via self. When an instance method is called, Cloesce will automatically hydrate the Model instance with the appropriate data before invoking the method. Each instance method may specify a Data Source to how self should be hydrated.

[use db]
model User {
    primary {
        id: int
    }

    name: string
    
    nav (Dog::ownerId) {
        dogs
    }
}

api User {
    get echo() -> string
    get myDogs(self) -> Array<Dog>
    get selfWithoutDogs([source NoDogs] self) -> User

    post updateName(self, newName: string) -> void
}

source NoDogs for User {
    include { }
}

After compilation, all methods of the User Model will be exposed as API endpoints, returning HTTP 501 (Not Implemented) until they are implemented on the Workers side.

An example implementation of the User Model methods in TypeScript might look like this:

import * as clo from "@cloesce/backend";

// Utilize the `impl` function to implement a Model's API methods.
// The only place `clo` is used in the entire backend is for this call,
// and the `User` object will inherit all of the generated methods and types from the schema.
const User = clo.User.impl({
    echo(input) {
        if (isBadWord(input)) {
            return HttpResult.fail(400, "I'm not saying that!");
        }

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

    myDogs(self){
        // Since this method was hydrated with the default Data Source, 
        // `self` includes the `dogs` Navigation Field.
        return self.dogs;
    },

    selfWithoutDogs(self) {
        // Since this method was hydrated with the `NoDogs` Data Source,
        // `self` does not include the `dogs` Navigation Field.
        return self;
    },

    async updateName(self, newName) {
        await this.Orm.save(self.env, { ...self, name: newName });
    }
});

The generated client API will have fully typed methods to invoke these endpoints as if they were Object Oriented methods on the class User.

CRUD Methods

Important

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

Note

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

When creating Models, you will find yourself writing the same CRUD (Create, Read, Update, Delete) boilerplate. To save this effort, Cloesce is capable of generating implementations for CRUD methods without any extra work. These methods are exposed as API endpoints. Internally, they simply run the Cloesce ORM operations.

[use db]
[use get, save, list]
model User {
    primary {
        id: int
    } 
}

Now, the client will have access to the methods User.$get, User.$save, and User.$list for free, which will call the corresponding API endpoints to perform those operations. The $get method takes in the primary key(s) of the Model to fetch a single instance, while the $list method takes in pagination parameters to fetch multiple instances.

By default, these methods use the default Data Source for the Model. However, each CRUD method accepts a Data Source as a parameter, allowing you to choose which Data Source to use when invoking the method. This provides flexibility in how much data is fetched when performing CRUD operations.

To hide a Data Source from the client CRUD methods, simply mark the Data Source as internal:

[internal]
source Internal for User {
    include {
        dogs
    }
}

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
doubleFloating-point numbers
intInteger values
boolBoolean values (true/false)
dateDate and time values
blobBinary data
DataSource<T>Any data source for Model type T
jsonJSON data of unknown structure
Partial<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)
Array<T>Arrays of any supported type (e.g., Array<string>, Array<User>)
Option<T>Nullable versions of any type
streamStream of data

Plain Old Objects

Note

Plain old objects can only consist of serializable properties supported by Cloesce. They cannot contain any 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.

poo Profile {
    bio: string
    age: int
    interests: Array<string>
}

model User {
    // ...
}

api User {
    get profile(self) -> Profile
    post updateProfile(self, profile: Profile) -> void
}

Partial

Cloesce provides a special utility type called Partial<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 stream type.

If a method parameter is of type stream, 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 stream, Cloesce will return a plain Response on the client side, allowing for efficient streaming of data back to the client.

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

model MediaFile {
    primary {
        id: int
    }

    r2(myBucket, "media/{id}.png") {
        file
    }
}

api MediaFile {
    post uploadFile(self, wrangler: env, file: stream) -> void
    get downloadFile(self) -> stream
}
import * as clo from "@cloesce/backend";

const MediaFile = clo.MediaFile.impl({
    async uploadFile(self, wrangler, file: CfReadableStream) {
        const key = this.Key.file(self.id);
        await wrangler.myBucket.put(key, file);
     },

    downloadFile(self) {
        if (!self.file) {
            return HttpResult.fail(404, "File not found.");
        }

        // Body is never loaded into memory, just streamed!
        return HttpResult.ok(self.file.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 in the generated backend code, as well as the cloesce libraries Orm class.

Generated Backend ORM

When you define a Model in Cloesce, three methods are generated for interacting with it get, list, and save.

  • save: Creates or updates a Model instance from a partial object, using some Include Tree for guidance in nested relationships. Returns a fully hydrated instance.

  • get: Retrieves a single instance. Optionally, accepts a D1PreparedStatement to fetch any D1 properties in the same query. Returns null if no matching row is found.

  • list: Retrieves all instances. Optionally, accepts a D1PreparedStatement to fetch any D1 properties in the same query. Not compatible with Models that require key parameters for KV or R2 properties.

While save is to be used in most scenarios, get and list are available for queries that require more complex application logic. For most cases, you can achieve the same result with a custom Data Source.

For example, given the following Cloesce Model:

model User {
    primary {
        id: int
    }

    name: string
}

source ByName for User {
    include { }

    sql get(name: string) {
        "SELECT * FROM User WHERE name = $name"
    }

    sql list(lastName: string, limit: int) {
        "SELECT * FROM User WHERE name > $lastName ORDER BY name LIMIT $limit"
    }
}

The generated backend code will create a method User.Source.ByName with get and list functions that execute the defined SQL and return hydrated Model instances.

Always accessible is the Default Data Source (User.Source.Default), which provides basic get and list methods without any custom SQL.

When implementing a Cloesce Model, these generated methods are placed directly on to the model:

const User = clo.User.impl({});

User.ByName.get(env, "Alice");
User.Default.get(env, 1);

Advanced ORM Usage

Internally, Cloesce uses the Orm class from the cloesce package to implement the generated methods described above. You may use it directly, or use the generated methods in the namespace of each backend Model, which are more convenient:

const User = clo.User.impl({});
User.Orm.hydrate(...);
User.Orm.map(...);
User.Orm.select(...);

select

Orm.select generates a SQL SELECT statement with the necessary LEFT JOINs and column aliases to retrieve all properties of a Model according to an Include Tree.

For example, given the Model

model Boss {
    primary {
        id: int
    }
    
    // ...
}

source WithAll for Boss {
    include {
        persons {
            dogs
            cats
        }
    }
}

Orm.select(Boss.Meta, null, Boss.Source.WithAll.include) 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"

select can also take a from string to wrap a subquery as the base table:

Orm.select(Boss.Meta, "SELECT * FROM Boss WHERE name = 'Alice'", Boss.Source.WithAll.include);

map

Orm.map takes the results of a SELECT query and reconstructs the object graph based on the column aliases. This is useful when you need to write custom SQL but still want to leverage the ORM’s hydration capabilities.

hydrate

Orm.hydrate takes a base set of Model instances (e.g. from map) and fetches any KV or R2 properties to return fully populated Model instances. Additionally, it instantiates objects like Dates and blobs according to the Model definition.

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 requires an env to be defined in any schema that uses D1, KV, or R2 bindings, which is used to define the Cloudflare Workers environment tailored to your application.

A wrangler.jsonc or wrangler.toml file is generated during compilation based on the env block. Configure your preference in the cloesce.jsonc file:

{
    "src_paths": ["./src/schema"],
    "workers_url": "http://localhost:5000/api",
    "wrangler_config_format": "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 env block.

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

An example Wrangler environment is shown below:

env {
    d1 { 
        db
        // ...
    }

    kv {
        kv
        // ...
    }

    r2 {
        bucket
        // ...
    }

    vars {
        someVariable: string
        otherVariable: int
    }
}

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",
        "otherVariable": 0
    }
}

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

service HelloWorldService {
    helloString: string
}

api HelloWorldService {
    hello(self) -> string
}

After running cloesce compile, a backend method can be implemented for the hello API:

import * as Cloesce from "@cloesce/backend";

class HelloWorldService extends Cloesce.HelloWorldService.Api {
    init(self: Cloesce.HelloWorldService.Self): void {
        self.helloString = "Hello, World!";
    }

    hello(self): string {
        return self.helloString;
    }
}

Dependency Injection

To share dependencies across your Cloesce application methods, Cloesce utilizes a dependency injection container. By default, Cloesce provides only the Wrangler environment as a dependency.

To define a custom dependency, simply add an inject block to your schema:

inject {
    MyDependency
}

This type can then be passed in to any api method or service definition and will be resolved by the dependency injection container at runtime. Additionally, typing a field with env will inject the Wrangler environment.

env {
    // ...
}

inject {
    YouTubeApiClient
}

service VideoService {
    wrangler: env
    ytClient: YouTubeApiClient
}

api VideoService {
    getVideo(self, videoId: string) -> stream

    // also valid
    getVideoStatic(wrangler: env, ytClient: YouTubeApiClient, videoId: string) -> stream
}

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 FooService { }

service BarService {
    foo: FooService
}

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.

Middleware Hooks

Warning

Middleware hooks are likely to change significantly before a stable 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 default {
    async fetch(request: Request, env: Cloesce.Env): Promise<Response> {
        if (request.method === "POST") {
            return HttpResult.fail(401, "POST methods aren't allowed.").toResponse();
        }

        const app = await Cloesce.cloesce();
        app.register(new Foo());

        app.onNamespace(Cloesce.Foo.Tag, (di) => {
            di.set(new InjectedThing("hello world"));
        })

        app.onMethod(Cloesce.Foo.Tag, "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 cloesce compile to generate the necessary files
  • Applied migrations for the Models being tested
  • Invoked cloesce to initialize the Cloesce runtime
import { cloesce } from "@cloesce/backend";
beforeAll(() => cloesce());

Cloesce needs only the CIDL (generated during compilation) 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