trb/orm
trb/orm is an experimental official package for typed database access. Its
generated runtime supports Go, Ruby, and TypeScript with SQLite, PostgreSQL,
and MySQL. TypeScript server applications currently use the Bun runtime.
When db/schema.lock.json exists, the compiler reads its deterministic type
contract and does not require a database connection during checks or builds.
Without a lock, it falls back to live schema introspection. The generated
application still connects to the database at runtime.
Migrations are not a runtime dependency of trb/orm. Use any schema tool, or
use the optional trb db workflow backed by sqldef.
Configuration
Configure the adapter and database source in trbconfig.jsonc. An environment
variable avoids embedding a database location in source or generated code:
{
"name": "my-app",
"mode": "go",
"sourceDir": "src",
"packageOptions": {
"trb/orm": {
"adapter": "sqlite",
"database": {
"environment": "DATABASE_URL"
}
}
},
"go": {
"module": "example.com/my-app"
}
}
SQLite environment values must be absolute paths. Adapter names are sqlite,
postgresql, and mysql. Set mode to ruby to generate a Ruby application;
the ORM source and package options stay the same. TypeScript projects must set
typescript.runtime to "bun".
The compiler looks for db/schema.lock.json by default. Set the optional
schemaLock package option to another project-relative path. If an explicitly
configured lock is missing or invalid, compilation fails instead of silently
using the live database.
Any database column mapped to TypeRB Integer uses TypeRB's portable exact
range -9007199254740991..9007199254740991; generated adapters report an
out-of-range column value as invalid database data.
Date and time columns
Schema introspection and schema locks expose portable time types without model annotations:
| TypeRB | PostgreSQL | MySQL | SQLite declared type |
|---|---|---|---|
Date |
date |
date |
DATE |
TimeOfDay |
time without time zone |
time |
TIME |
DateTime |
timestamp without time zone |
datetime |
DATETIME or TIMESTAMP |
Instant |
timestamp with time zone |
timestamp |
TIMESTAMPTZ or INSTANT |
These values work in model fields, typed predicates, writes, projections, and
minimum() / maximum(). Instant database traffic is normalized through
UTC, and generated runtimes use UTC database sessions so server-evaluated
defaults behave consistently across targets. DateTime never gains an implicit
timezone. MySQL TIME is accepted only within the portable TimeOfDay range
from 00:00:00 through 23:59:59.999999. Database precision remains controlled
by the column declaration, such as timestamp(6).
Models and associations
Model and table names follow conventions. Fields, nullability, primary keys, unique constraints, and foreign keys come from the live schema.
Each source directory is one ORM model group. Models in separate files within that directory reference one another in association declarations without source imports:
# src/models/user.trb
import { Model, has_many } from trb/orm
class User < Model
has_many(Post, dependent: :destroy)
end
# src/models/post.trb
import { Model, belongs_to } from trb/orm
class Post < Model
belongs_to(User, name: :author)
end
The first argument of belongs_to, has_many, and has_one is a
compiler-resolved declaration reference. This exception does not make the
target model available to ordinary expressions or type annotations. Import it
normally when using it as a query root, constructor, parameter, or return type:
import models/post
import { DbResult } from trb/orm
def recent_posts(): DbResult<Array<Post>>
return Post.order(created_at: :desc).limit(20).all()
end
A subdirectory starts another model group. Every model traversed by a direct or through association must remain in one group; direct association inverses and dependent lifecycle targets follow the same boundary. The compiler reports both declarations when that boundary is crossed. Database foreign-key columns may still cross groups. Keep the identifier and load the other record through an application query or repository instead of ORM object navigation. Model class names are currently unique across the project.
Completion, hover, definition, references, and rename understand declaration references in association arguments and do not insert target-model imports. Generated runnable entrypoints bootstrap Ruby and TypeScript model registration without introducing model-to-model initialization imports; Go emits the group as one generated package. A model module must not import the runnable entrypoint directly or transitively, because the registration bootstrap would close an initialization cycle. Move declarations shared with the entrypoint into a separate module; the compiler reports the complete cycle.
Use name, foreign_key, references, inverse, through, and source
only when conventions are insufficient. Association scopes are typed blocks:
class User < Model
has_many(Post, name: :published_posts) do |posts|
posts.where(published: true).order(created_at: :desc)
end
end
Enum columns
Use enum_column when a schema column stores a domain enum. Model fields,
query values, writes, and REPL results then use the nominal enum type rather
than its database scalar type:
import { Model, enum_column } from trb/orm
enum OrderStatus
Pending = "PENDING"
Completed = "COMPLETED"
end
enum FulfillmentPhase
PendingReview
ReadyToShip
end
class Order < Model
enum_column(:status, OrderStatus)
enum_column(:phase, FulfillmentPhase)
end
Raw-value enums store their exact String or Integer raw values. Ordinary enums
use lower snake case, so PendingReview maps to pending_review. Every mapping
is checked against the schema or schema lock during compilation. Nullability
still comes from the database column, and an unknown stored value is reported
as DbErrorKind::InvalidData instead of being accepted as an enum member.
The enum may be declared in the application or imported from a TypeRB package;
package aliases use the same resolution rules as ordinary imports.
Queries and Results
Model classes are query roots; an empty where() is unnecessary. Query values
are immutable, and database terminals return DbResult<T>, the package alias
for Result<T, DbError>.
import { DbResult } from trb/orm
def recent_posts(): DbResult<Array<Post>>
query := Post.where(published: true).order(created_at: :desc).limit(20)
return query.all()
end
The three-argument form supports typed comparison operators. String columns
also accept LIKE; % matches any sequence and _ matches one character.
The pattern is passed as a bound value, while matching and case sensitivity
follow the database and its collation.
matching := Post.where("title", "LIKE", "%TypeRB%")
Use prefix try when a function performs more work after a successful
database operation. Use catch when the caller resolves the database error
locally:
def main()
posts := recent_posts() catch |error|
puts(error.message)
return
end
puts(posts.size())
end
Associations use the same Result rules. user.posts and post.author return a
DbResult that loads on first access and caches its success value. Preload
fills the same cache. load() and reload() also return DbResult; loaded?()
remains an ordinary Boolean cache query.
import { DbResult } from trb/orm
def print_posts(): DbResult<Integer>
users := try User.preload(:posts).all()
users.each do |user|
posts := try user.posts
posts.each do |post|
puts(post.title)
end
end
return DbResult<Integer>::Ok(users.size())
end
Streaming in batches
find_each() and find_in_batches() are structured Result boundaries. They
return DbResult<Integer>, where the success value is the number of records
visited. Assign the raw Result when both outcomes matter, use prefix try
inside a function returning a compatible Result, or use catch to recover at
the call site:
import { DbResult } from trb/orm
def print_recent_posts(): DbResult<Integer>
count := try Post.where(published: true).find_each(batch_size: 100) do |post|
puts(post.title)
end
return DbResult<Integer>::Ok(count)
end
def main()
count := print_recent_posts() catch |error|
puts(error.message)
0
end
puts(count)
end
Prefix try inside the streaming block stops iteration and returns Err from
the streaming operation. break and next keep their ordinary local
iteration behavior. An authored return cannot cross this Result boundary;
return after the operation instead. When streaming runs inside
Database.transaction(), an Err propagated with try rolls the transaction
back before an outer catch handler runs.
Writes and transactions
Drafts and pending changes keep inserts and updates distinct:
import { DbResult } from trb/orm
def create_post(user: User): DbResult<Post>
draft := Post.build(user_id: user.id, title: "First post")
post := try draft.save()
return post.with(title: "Updated post").save()
end
Bulk insert, conflict-aware insert, upsert, relation update/delete, batching, aggregates, joins, subqueries, row locks, and destroy lifecycles are available. Transactions use an explicit scope:
import { DbResult } from trb/orm
def publish_all(): DbResult<Integer>
return Database.transaction() do |transaction|
posts := Post.using(transaction)
try posts.where(published: false).update_all(published: true)
end
end
Database.transaction() is a structured Result boundary. A Result propagated
with try from its body rolls the transaction back; an outer catch runs only
after rollback completes. The block's final successful value becomes the
transaction's Ok payload. An authored return cannot cross the transaction
boundary.
Model classes and filtered relations both support update_all(), delete_all(),
and destroy_all(). Direct deletes skip lifecycle behavior; destroy operations
run the configured dependent: destroy, delete, nullify, or restrict rule.
REPL
Start trb or trb repl in the project. The REPL loads the same schema and can
execute reads, associations, transactions, batching, writes, conflicts, and
destroy lifecycles. A top-level database Result displays Ok or its structured
DbError without ending the session. Top-level try is rejected; inspect the
Result directly or recover with catch so an error cannot terminate the
interactive session implicitly.