Skip to content

Define your data

This page is everything about the schema — your data model as one TypeScript value (Ramose calls it a catalogglossary). It is for anyone who has built the first app and wants to model real data.

Three pieces, and that is the whole vocabulary

Section titled “Three pieces, and that is the whole vocabulary”
  • Ramose.Attr declares one field — a named, typed slot (glossary) — and the values it accepts.
  • Ramose.Namespace groups fields into a record type — a kind of record, like a table (glossary). Issue.title is :issue/title on the wire; you always write Issue.title.
  • Ramose.Catalog collects record types into the schema a database installs.

Value types come from Effect Schema, plus a few of Ramose’s own.

Reef’s whole data model is 76 lines. The parts that matter:

examples/reef/src/domain/schema.ts:15-27
export const User = Ramose.Namespace("user", {
sub: Ramose.Attr(Schema.String, {
unique: "identity",
doc: "Better Auth user id — the JWT `sub`; the policy resolves principals through it",
}),
name: Ramose.Attr(Schema.String),
email: Ramose.Attr(Schema.String),
});
export const Label = Ramose.Namespace("label", {
name: Ramose.Attr(Schema.String, { unique: "identity" }),
color: Ramose.Attr(Schema.String),
});
examples/reef/src/domain/schema.ts:29-46
export const Issue = Ramose.Namespace("issue", {
title: Ramose.Attr(Schema.String),
description: Ramose.Attr(Schema.String),
/** One of {@link STATUSES}. */
status: Ramose.Attr(Schema.String),
/** 0 none · 1 low · 2 medium · 3 high · 4 urgent. */
priority: Ramose.Attr(Ramose.Long),
/** Fractional order inside a column; drag-and-drop writes midpoints. */
rank: Ramose.Attr(Schema.Number),
createdAt: Ramose.Attr(Ramose.Instant),
creator: Ramose.Attr(Ramose.Ref(() => User)),
assignee: Ramose.Attr(Ramose.Ref(() => User)),
labels: Ramose.Attr(Ramose.Ref(() => Label), { cardinality: "many" }),
/** Admin-only field — the policy narrows its `read` (see policy.ts). */
privateNote: Ramose.Attr(Schema.String, {
doc: "visible to the admin class only",
}),
});

Comment (schema.ts:48-53) has the same shape, and Reef = Ramose.Catalog({ user, label, issue, comment }) (schema.ts:55-60) collects the four.

fieldwhat it means
user.subThe account id from the sign-in system. unique: "identity" = one record (glossary) per person, and you can look a user up by it. Used by permissions later.
issue.priority, issue.rankA whole number (Ramose.Long); a double (Schema.Number).
issue.creatoruserA reference — a typed foreign key (glossary). Permissions fill it in for you.
issue.labelslabelA set of references — no join table.
issue.privateNoteAdmin-only text; the policy masks it for everyone else.

Every record type also gets an id field for free (Issue.id).

schemastores
Schema.String · Schema.Booleantext · true/false
Schema.Numbera double (Issue.rank)
Ramose.Longa whole number, stored as a long (Issue.priority) — it is a JavaScript number, so nothing above 2⁵³ is exact
Ramose.Instanta point in time — you pass and receive a Date
Ramose.Ref(() => User) · Ramose.Ref.selfa reference to a record of that type · of the same type
Ramose.Refan untargeted reference — stores fine, but queries cannot hop through it
Ramose.UuidString · Ramose.Uuida UUID as a string · as a structured value object (not a string)
Ramose.Bytesbinary data (Uint8Array)

String, Number, and Boolean are inferred; anything else — a Schema.Literal, say — needs { valueType: ":db.type/string" }, or installing the schema fails with ramose/schema: cannot infer :db.type/*.

optiondefaulteffect
uniquenone"identity" makes the field a unique key (glossary): one record per value; [User.sub, "…"] looks it up
cardinality"one"one value or many (glossary): "many" makes the field a set — a second value adds rather than replaces
indextrue when unique is setkeeps a value-ordered index, so you can look a record up by value
isComponentfalsethe referenced record belongs to its parent and is deleted with it. Its .reverse is one record, not an array — a component has at most one owner
docnonea description stored with the field
valueTypeinferredthe stored type, when it cannot be inferred

Ramose.Ref(() => User) points at another record. Naming the target lets a query hop through it (Issue.assignee.name) and read backwards (Issue.creator.reverse — the issues that point at this user). The arrow function lets two record types point at each other.

Nothing downstream needs a type annotation. Reef’s board row is inferred:

examples/reef/src/domain/queries.ts:90-91
/** One row of {@link boardQuery} — inferred from the query, never restated. */
export type BoardRow = Ramose.Row<typeof boardQuery>;

BoardRow["priority"] is a number, createdAt a Date, assignee { id; name } | undefined. Writes are checked too: issue.add(Issue.priority, "high") does not compile. Your UI types are your database types; they cannot drift.

ramose.db(name, Reef) names a database without touching the network, so something must install the schema — an ordinary write, safe to repeat (glossary). Two doors.

At deploy, for a database you know up front (the todos app):

examples/todos/alchemy.run.ts:34
export const TodosDb = Ramose.Database("todos", { server: Server, catalog: Todos });

From the app, for names created at runtime — how Reef makes a workspace:

examples/reef/src/app/mutations.ts:28-33
export const provisionWorkspace = (
db: ReefDb,
me: { id: string; name: string; email: string },
) =>
Effect.gen(function* () {
yield* db.install();

Which door, and why: One database per customer.

Adding a field or a record type is another install; the app keeps running. There is no destructive migration to fear, because Ramose never rewrites a fact — one statement about one record’s field (glossary). Old data keeps the field it was written with, reads of the past keep working, and removing a field from the schema does not delete the facts that used it.

Three changes are not guarded: a field’s value type, whether it holds one value or many, and whether it is a unique key. Existing data is neither re-typed nor checked — add a new field instead. Dropping an option is quieter still: nothing goes over the wire, so the old setting stays. Under a policy, only the admin role may change the schema.