Nebula ORM
Comet's D1-first ORM core — entities, typed columns, query builder, relationships, and migrations.
Nebula is the D1-first ORM core embedded in the comet crate
(src/nebula/), designed to give Comet applications an ergonomic data layer
with no hidden request-time cost. It's entirely optional — none of it is
compiled unless the nebula feature is enabled.
Philosophy
- Keep route code close to normal Rocket ergonomics.
- Treat D1 (SQLite-compatible) as the first-class backend — no cross-database abstraction layer.
- Generate deterministic SQL with explicit bind values — no runtime reflection, no query-planner "magic".
- Generate migrations outside of the Worker's request handling — never at request time.
- Keep Comet's hot path free of Nebula unless the feature is enabled.
- Preserve a raw-SQL escape hatch for anything the builder doesn't model (recursive CTEs, FTS, hand-tuned plans).
Deliberate non-goals: no runtime schema sync, no automatic production migrations from inside a Worker, no cross-database portability, no general relational mapper with implicit joins or lazy loading, and no query planning that hides D1's rows-read/rows-written cost.
#[derive(Entity)]
Defined in comet-macros, re-exported as comet::nebula::Entity. Only
works on structs with named fields.
Struct-level attributes (#[nebula(...)] on the struct):
table = "tasks"— the table name (default: snake_case of the struct name).crate = "::my_crate"— the path used to referencecometin generated code.
Field-level attributes:
| Attribute | Effect |
|---|---|
primary_key | Marks the column as PRIMARY KEY (at most one per entity). |
auto / auto_increment | Adds AUTOINCREMENT; requires an integer field. |
unique | Adds a UNIQUE constraint. |
index / indexed | Generates a single-column index in the migration. |
nullable / nullable = true|false | Controls NOT NULL. |
default = "0" | SQL default expression (DEFAULT <expr>). |
rename = "created_at" | Column name, if different from the field. |
foreign_key = "boards.id" | table.column syntax; generates FK metadata and, in migrations, FOREIGN KEY (...) REFERENCES .... |
Rust type → SqlType mapping: integers (i8..i64, u8..u64,
isize/usize) → Integer; f32/f64 → Real; String/str → Text;
Vec → Blob; bool → Boolean. Any other type is a compile error.
Primary keys don't need to be integers — String works for
application-owned IDs — but auto/auto_increment requires an integer
field.
#[derive(comet::nebula::Entity)]
#[nebula(table = "tasks")]
pub struct Task {
#[nebula(primary_key, auto)]
pub id: i64,
#[nebula(index)]
pub title: String,
#[nebula(index)]
pub done: bool,
#[nebula(foreign_key = "boards.id", index)]
pub board_id: i64,
pub created_at: String,
}This generates associated constants Task::ID, Task::TITLE,
Task::DONE, Task::BOARD_ID, Task::CREATED_AT (all Column<T>) and an
impl Entity for Task with a const TABLE: TableDef.
D1/SQLite has no native boolean storage type. Booleans become INTEGER
(0/1) in the generated schema, but are still tracked as their own
SqlType for tooling.
Column<T>: the typed column API
Column<T> is a typed handle (via PhantomData), const-constructible,
carrying the table and column name. Comparison methods build an Expr (SQL
fragment + bind values + referenced columns):
eq,ne,gt,gte,lt,lte— each takesimpl Into<Value>.like(value)— rawLIKE ?.like_escaped(needle)— wrapsneedlein%...%and escapes\,%,_, so user input can't inject wildcards.is_null/is_not_null.asc()/desc()— build anOrderingforORDER BY.
let expr = Task::TITLE.like_escaped("50%_off\\sale");
// sql: "tasks"."title" LIKE ? ESCAPE '\'
// binds: [Value::Text("%50\%\_off\\sale%")]
let filter = Task::DONE.eq(false).and(Task::TITLE.like("%docs%"));Expr supports .and(other) / .or(other), combining SQL as
(left) AND (right) and merging binds/columns in order. Identifiers are
always double-quoted.
Value and SQL types
pub enum Value {
Null,
Integer(i64),
Real(f64),
Text(String),
Blob(Vec<u8>),
Bool(bool),
}From is implemented for i64/i32/u32 → Integer, f64 → Real,
bool → Bool, String/&str → Text, Vec<u8>/&[u8] → Blob.
SqlType maps Rust field types to SQL column types in the DDL:
Integer→INTEGER, Real→REAL, Text→TEXT, Blob→BLOB,
Boolean→INTEGER.
Query builder
Entity provides select(), insert(), update(), delete(), all
terminating in .to_statement() → Statement { sql: String, binds: Vec<Value> }.
There are no joins — Nebula isn't a fully general relational mapper.
let statement = Task::select()
.where_(Task::DONE.eq(false))
.and_where(Task::TITLE.like("%docs%"))
.order_by(Task::CREATED_AT.desc())
.limit(50)
.offset(10)
.to_statement();
// SELECT "id", "title", "done", "created_at" FROM "tasks"
// WHERE ("tasks"."done" = ?) AND ("tasks"."title" LIKE ?)
// ORDER BY "tasks"."created_at" DESC LIMIT ? OFFSET ?Select also supports .columns([...]) to project a subset,
.allow_full_table_scan(), and .allow_unbounded_select() (escape hatches
for the lints, see below).
Task::insert()
.set(Task::TITLE, "write tests")
.set(Task::DONE, false)
.returning(["id", "title", "done", "created_at"])
.to_statement();
// INSERT INTO "tasks" ("title", "done") VALUES (?, ?)
// RETURNING "id", "title", "done", "created_at"
Task::update()
.set(Task::DONE, true)
.where_(Task::ID.eq(42))
.to_statement();
// UPDATE "tasks" SET "done" = ? WHERE "tasks"."id" = ?
Task::delete().where_(Task::ID.eq(42)).to_statement();
// DELETE FROM "tasks" WHERE "tasks"."id" = ?Bind order is always deterministic: assignment/filter binds first, then
LIMIT/OFFSET at the end.
Row-level security
D1 uses SQLite semantics and does not provide Postgres-style native RLS.
Nebula's RLS is enforced in the ORM layer by generating additional predicates
and validating authorization before statements are executed. Raw SQL remains
available, but it is outside automatic RLS enforcement; use
Statement::raw_unscoped(...) so reviews and searches can identify those
manual authorization sites.
Declare RLS on the entity:
#[derive(comet::nebula::Entity)]
#[nebula(table = "tasks")]
#[nebula(rls(owner = "user_id"))]
#[nebula(rls(update, permission = "tasks:write"))]
#[nebula(rls(update, custom = "can_complete_task"))]
pub struct TaskRow {
#[nebula(primary_key, auto)]
pub id: i64,
#[nebula(index)]
pub user_id: String,
pub done: i32,
}Supported first-class policy forms:
#[nebula(rls(public))]— explicitly public table.#[nebula(rls(owner = "user_id"))]— matches the current user id.#[nebula(rls(tenant = "org_id"))]— matches the current tenant id.#[nebula(rls(select, permission = "boards:read"))]— RBAC per operation.#[nebula(rls(update, any(role = "admin", permission = "boards:write")))]— any/all RBAC matching.#[nebula(rls(delete, custom = "can_delete_board"))]— app-provided predicate.
Use scoped builders with an AccessContext:
let context = comet::nebula::AccessContext::authenticated(user_id);
let tenant_context = comet::nebula::AccessContext::default()
.with_tenant_value(42_i64);
let statement = TaskRow::select_scoped(&context)?
.where_(TaskRow::DONE.eq(0))
.limit(50)
.to_statement();Owner and tenant values are stored as Nebula Values, so text ids, integer
ids, booleans, blobs, and other supported bind types keep their original SQL
bind type. authenticated(...) and with_tenant(...) are string conveniences;
use authenticated_value(...), with_user_value(...), or
with_tenant_value(...) for non-text keys.
Multiple policies that apply to the same operation compose with AND.
Use this for defense in depth, such as owner/tenant filtering plus RBAC plus a
custom lifecycle predicate. If you need OR semantics for row predicates,
encode that in one custom predicate.
When comet-auth is built with its nebula feature, sessions can build an
access context directly:
use comet_auth::NebulaAccessContextExt;
let context = session.to_nebula_access_context();Custom predicates implement CustomPredicateProvider and return a Nebula
Expr:
struct TaskPredicates;
impl comet::nebula::CustomPredicateProvider for TaskPredicates {
fn predicate(
&self,
table: &'static str,
name: &'static str,
_operation: comet::nebula::RlsOperation,
_context: &comet::nebula::AccessContext,
) -> Result<comet::nebula::Expr, comet::nebula::RlsError> {
if table == TaskRow::TABLE.name && name == "can_complete_task" {
Ok(TaskRow::DONE.eq(0))
} else {
Err(comet::nebula::RlsError::MissingCustomPredicate { table, name })
}
}
fn registered_predicate_rules(
&self,
) -> &'static [comet::nebula::CustomPredicateRegistration] {
&[comet::nebula::CustomPredicateRegistration {
name: "can_complete_task",
operations: &[comet::nebula::RlsOperation::Update],
}]
}
}Use custom rules when ownership, tenancy, and static RBAC are not enough:
- lifecycle rules, such as "only complete pending tasks";
- membership rules, such as "only active project members";
- mixed rules, such as "creator or admin";
- feature rules, such as "only if the workspace has billing enabled".
Keep custom predicate names stable because entity metadata stores the string.
Expose those names through registered_predicate_rules() to validate the
operation too (select, insert, update, delete), or use
registered_predicates() only when the same predicate is intentionally valid
for every operation. Validate startup or route setup with
Entity::validate_custom_predicates_with(...). Test the allow path, denied
path, and missing-predicate path. A missing custom predicate is treated as a
server/configuration error.
select(), insert(), update(), and delete() on protected tables report
QueryLint::UnscopedRls unless RLS was applied or
.allow_unscoped_rls("reason") is used explicitly. Schema lints also report missing
RLS declarations and owner/tenant columns that are not indexed.
Use checked statement generation to fail closed on lints:
let statement = TaskRow::select_scoped(&context)?
.limit(50)
.to_statement_checked()?;The CLI can report entity coverage:
comet rls status --strict
comet rls status --strict --json \
--custom-predicate-rule can_complete_task:updateRLS metadata is part of schema snapshots. Changing RLS policies in an existing table is reported as a migration blocker so the authorization change is reviewed explicitly.
Threat model: Nebula RLS protects queries built through scoped Nebula builders
when the app supplies the correct AccessContext. It does not protect raw SQL,
incorrect tenant context, custom predicates that are too broad, or data access
that bypasses Nebula. Treat Statement::raw_unscoped(...) and
.allow_unscoped_rls("reason") as security review points.
Relationships
Relationships are explicit query-builder shortcuts, not lazy loading or
implicit joins. Two generic wrappers, built via
belongs_to()/has_many(), pair a local column with a foreign column:
impl Task {
const BOARD: BelongsTo<Task, Board, i64> = belongs_to(Self::ID, Board::ID);
}
impl Board {
const TASKS: HasMany<Board, Task, i64> = has_many(Self::ID, Task::ID);
}BelongsTo::select_parent(local_value)returns a normalSelect<Parent>(Parent::select().where_(foreign_column.eq(local_value)).limit(1)).HasMany::select_children(parent_value)returns aSelect<Child>— the caller still picks the limit/ordering.select_parent_scoped(...)andselect_children_scoped(...)apply RLS on the loaded side of the relationship with the sameAccessContext; the*_scoped_with(...)variants accept aCustomPredicateProvider.
let statement = Task::BOARD.select_parent(42).to_statement();
// SELECT "id", "name" FROM "boards" WHERE "boards"."id" = ? LIMIT ?
let statement = Board::TASKS.select_children(7)
.order_by(Task::ID.asc())
.limit(50)
.to_statement();
let statement = Board::TASKS.select_children_scoped(7, &context)?
.order_by(Task::ID.asc())
.limit(50)
.to_statement_checked()?;Migration generation
SchemaManifest (a sorted Vec<TableDef>) is the migration engine:
initial_migration()→Vec<String>ofCREATE TABLE/CREATE INDEXstatements for a from-scratch schema.diff(&desired)→MigrationPlan { statements, blockers }, a safe additive diff.to_manifest_string()→ a deterministic text snapshot (for tests/tooling, not SQL).lint()→Vec<SchemaLint>.
CREATE TABLE "tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT,
"title" TEXT NOT NULL, "done" INTEGER NOT NULL, "created_at" TEXT NOT NULL)
CREATE INDEX "idx_tasks_done_created_at" ON "tasks" ("done", "created_at")Safe diffs (become SQL automatically): missing tables, nullable
columns, columns with a default, missing indexes/unique indexes — for
example ALTER TABLE "tasks" ADD COLUMN "done" INTEGER NOT NULL DEFAULT 0.
Blocked diffs (return a MigrationBlocker, requiring human review, no
SQL emitted): DropTable, DropColumn, ChangeColumn,
UnsafeAddColumn (non-nullable column with no default),
DropIndex/ChangeIndex, AddForeignKey/DropForeignKey/ChangeForeignKey
on existing tables, and ChangeRls when RLS policies change on an existing
table. MigrationPlan::is_safe() is blockers.is_empty().
MigrationPlan::migration_file_name(sequence, name) produces
Wrangler-compatible names, like 0007_add_task_done.sql.
Schema snapshots (nebula-schema)
SchemaSnapshot is an owned, JSON-serializable mirror of
SchemaManifest — needed because TableDef/ColumnDef hold
&'static str/slices, which can't be deserialized from a persisted file.
Tooling calls SchemaSnapshot::from_manifest() to persist the "current"
schema after a successful migration, and snapshot.to_manifest() to
rebuild a SchemaManifest and compare it against the entities declared in
code today. This is exactly what the comet migrate commands in
comet-cli use.
Running against D1 (nebula-d1)
The nebula-d1 feature adds execution methods directly on Statement:
prepare_d1(&db)— binds theValues to aworker::D1PreparedStatement.execute_d1(&db)—.run(), for writes.fetch_all_d1(&db)—.all(), returns the rawD1Result.fetch_optional_d1::<T>(&db)/fetch_one_d1::<T>(&db)— deserialize intoT: Deserialize;fetch_one_d1errors if no row is found.batch_d1(db, statements)— prepares multipleStatements and callsdb.batch(...), which D1 executes transactionally (all or nothing) — a separate, explicit call, so ordinary single-statement execution never implies transaction semantics.
let row = TaskRow::select()
.where_(TaskRow::ID.eq(id))
.to_statement()
.fetch_optional_d1::<TaskRow>(&db)
.await?
.ok_or(ApiError::NotFound)?;Lints
Nebula's lints are advisory only — they don't change the generated SQL
or run automatically; the caller invokes .lint() explicitly.
QueryLint (from queries):
MissingLimit— aSelectwithout.limit()(suppressed by.allow_unbounded_select()).UnindexedFilter { column }/UnindexedOrdering { column }— filter/order-by columns that aren't a primary key, unique, indexed, or the left-most column of a composite index (suppressed by.allow_full_table_scan()).BroadUpdate/BroadDelete—Update/Deletewith noWHERE(suppressed by.allow_broad_write()).
SchemaLint::UnindexedForeignKey { table, column } (from schema) —
flags foreign-key columns without an index, since D1 relationship lookups
shouldn't rely on a table scan.
These lints map directly to D1's cost model (rows read/written): the goal is to make expensive query shapes visible, not to silently optimize or block them.
Feature summary
| Feature | Enables |
|---|---|
nebula | The ORM core: Entity, metadata, Column<T>, Value, Select/Insert/Update/Delete, relationships, lints, migration SQL generation. No worker/D1 dependency. |
nebula-d1 | Execution helpers in src/nebula/d1.rs against worker::D1Database. Enables nebula, cloudflare-d1, serde. |
nebula-schema | SchemaSnapshot for persisting/comparing schema state, used by comet-cli's comet migrate. Enables nebula, serde (no cloudflare/worker). |