Define your data
This page is everything about the schema — your data model as one TypeScript value (Ramose calls it a catalog — glossary). 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.Attrdeclares one field — a named, typed slot (glossary) — and the values it accepts.Ramose.Namespacegroups fields into a record type — a kind of record, like a table (glossary).Issue.titleis:issue/titleon the wire; you always writeIssue.title.Ramose.Catalogcollects record types into the schema a database installs.
Value types come from Effect Schema, plus a few of Ramose’s own.
Reef’s schema, annotated
Section titled “Reef’s schema, annotated”Reef’s whole data model is 76 lines. The parts that matter:
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),});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.
| field | what it means |
|---|---|
user.sub | The 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.rank | A whole number (Ramose.Long); a double (Schema.Number). |
issue.creator → user | A reference — a typed foreign key (glossary). Permissions fill it in for you. |
issue.labels → label | A set of references — no join table. |
issue.privateNote | Admin-only text; the policy masks it for everyone else. |
Every record type also gets an id field for free (Issue.id).
Value types
Section titled “Value types”| schema | stores |
|---|---|
Schema.String · Schema.Boolean | text · true/false |
Schema.Number | a double (Issue.rank) |
Ramose.Long | a whole number, stored as a long (Issue.priority) — it is a JavaScript number, so nothing above 2⁵³ is exact |
Ramose.Instant | a point in time — you pass and receive a Date |
Ramose.Ref(() => User) · Ramose.Ref.self | a reference to a record of that type · of the same type |
Ramose.Ref | an untargeted reference — stores fine, but queries cannot hop through it |
Ramose.UuidString · Ramose.Uuid | a UUID as a string · as a structured value object (not a string) |
Ramose.Bytes | binary 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/*.
Options
Section titled “Options”| option | default | effect |
|---|---|---|
unique | none | "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 |
index | true when unique is set | keeps a value-ordered index, so you can look a record up by value |
isComponent | false | the 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 |
doc | none | a description stored with the field |
valueType | inferred | the stored type, when it cannot be inferred |
References between records
Section titled “References between records”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.
Types flow out of the schema
Section titled “Types flow out of the schema”Nothing downstream needs a type annotation. Reef’s board row is inferred:
/** 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.
Where the schema gets installed
Section titled “Where the schema gets installed”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):
export const TodosDb = Ramose.Database("todos", { server: Server, catalog: Todos });From the app, for names created at runtime — how Reef makes a workspace:
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.
Changing a schema later
Section titled “Changing a schema later”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.