Skip to content

Schemas and traits

The schema is the durable vocabulary of the database. Spend time on names, identity, cardinality, and ownership before writing screens: those choices flow into queries, policies, operations, offline data, and MCP discovery.

import * as Ramose from "ramose/db"
export const User = Ramose.Entity("user", {
authId: Ramose.Field.unique(Ramose.string(), "strict", {
doc: "Stable subject from the identity provider",
}),
name: Ramose.string(),
})
export const Task = Ramose.Entity("task", {
title: Ramose.string({ doc: "Short description" }),
status: Ramose.enumeration(["todo", "doing", "done"]),
createdAt: Ramose.timestamp(),
assignee: Ramose.ref(User, { optional: true }),
})
export const ProjectSchema = Ramose.Schema("project-v1", {
user: User,
task: Task,
})
ProjectSchema.applyPolicy(({ policy }) => {
policy.user.read.never()
policy.task.read.never()
})

The first argument is the permanent schema key. Each object key matches the entity name. Each entity also exposes id for query shapes.

ConstructorApplication value
string()string
boolean()boolean
int() / float()JavaScript number
timestamp()Date
uuid()canonical UUID string
bytes()Uint8Array
Enum([…])the listed string union
Ref(EntityOrTrait)typed reference

Ramose.Field(effectSchema) is the advanced form. Use stored(schema, valueType) when the Effect Schema alone cannot determine a storage type.

const Document = Ramose.Entity("document", {
slug: Ramose.Field.unique(Ramose.string(), "strict"),
tags: Ramose.Field.many(Ramose.string()),
blocks: Ramose.Field.many(Ramose.Field.owned(Ramose.ref(Block))),
})
  • A normal field holds one value. Setting another replaces the current value.
  • Field.many is a set of values, not an ordered array.
  • A strict unique value rejects collisions. An identifying unique value may resolve an existing entity during creation.
  • Field.owned declares a cascade from the parent to the referenced child. Required references do not cascade automatically.

Use explicit rank or position fields for order. Do not infer order from creation or storage sequence.

Optional, indexed, defaulted, and fixed fields

Section titled “Optional, indexed, defaulted, and fixed fields”

Field options include optional, index, and doc. Creation defaults are computed inside the authoritative operation; fixed values come from trait bindings and cannot be supplied by callers or changed later.

Index fields used for equality, range filters, ordering, or lookup. Avoid indexing large text that is never filtered or sorted.

export const Timestamped = Ramose.Trait("timestamped", {
createdAt: Ramose.timestamp(),
updatedAt: Ramose.timestamp(),
})
export const Assignable = Ramose.Trait("assignable", {
assignee: Ramose.ref(User, { optional: true }),
})
export const Task = Ramose.Entity(
"task",
{ title: Ramose.string() },
{ traits: [Timestamped, Assignable] },
)

Use a trait when several concrete entities share semantics that should be queried, referenced, authorized, documented, or mutated polymorphically. If fields merely share spelling, a TypeScript helper may be enough.

Trait fields keep the trait’s durable identity while flattening onto composers. Traits may compose other traits. Cycles and public-name collisions are rejected.

Write doc text as a contract: state the business meaning, valid unit, safety implication, and surprising nullability. Operation docs should describe effects and retry behavior. MCP discovery uses the same metadata; there is no second agent registry.

Usually additive: a new entity, trait, operation, optional field, index, or documentation. Potentially incompatible: changing value type, cardinality, unique semantics, ownership, fixed binding, concrete type meaning, or reusing a stable operation key for different behavior.

Ramose applies the current deployed catalog to current and historical facts. Recataloging a database is unsupported. Plan incompatible changes as a fresh database or an explicit application migration.