Getting started
You are going to build a todo app from an empty folder. It stores todos in a Ramose database, and the list updates itself in every open tab — no refetch code, no WebSocket server, no REST endpoints. Everything installs from npm; you do not need to clone anything, and you do not need a Cloudflare account to run it.
You need Bun 1.x (curl -fsSL https://bun.sh/install | bash). Ramose is pre-release: bun add writes a caret range, and once the app works you may want to pin an exact version, because the API moves between releases.
Create the app
Section titled “Create the app”mkdir my-todos && cd my-todosbun add ramose react react-dombun add -d vite @vitejs/plugin-react typescript @types/react @types/react-domOne package. Ramose brings Effect 4 and Alchemy 2 — both prereleases — with it at versions that resolve, so there is no @rc to remember and nothing else to pin, alchemy included. React is an optional peer: a server-only app installs ramose alone. Already using Effect? Read this first.
Now a .env file. alchemy dev reads it automatically:
CI=1CLOUDFLARE_ACCOUNT_ID=0123456789abcdef0123456789abcdefCLOUDFLARE_API_TOKEN=xCI=1 is the one that does the work: it tells Alchemy to take credentials from the environment instead of a login profile. The other two are placeholders the local emulator insists on before it will start — the account id has to be 32 hex characters, the token can be any string, and nothing is uploaded anywhere. You swap in real ones only when you deploy.
Without them every resource fails and alchemy dev keeps running rather than exiting, so the terminal looks busy while nothing comes up. What that looks like.
Add .env and .alchemy/ to a .gitignore now — the first holds a real Cloudflare token once you deploy, the second is your entire local database:
node_modules/.alchemy/.envbun add wrote a package.json containing only dependencies. Merge these two keys into it — do not replace the file, or you will delete everything you just installed. Without "type": "module", Vite warns that your config is loaded as CommonJS:
"type": "module", "scripts": { "dev": "alchemy dev" },Three small files finish the setup:
{ "compilerOptions": { "target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "bundler", "strict": true, "skipLibCheck": true, "allowImportingTsExtensions": true, "noEmit": true, "jsx": "react-jsx", "types": ["vite/client"] }}import react from "@vitejs/plugin-react";import { defineConfig } from "vite";
export default defineConfig({ plugins: [react()] });<!doctype html><html lang="en"> <head><meta charset="utf-8" /><title>todos</title></head> <body> <div id="root"></div> <script type="module" src="/src/main.tsx"></script> </body></html>Describe your data
Section titled “Describe your data”import * as Ramose from "ramose/db";import * as Schema from "effect/Schema";
export const Todo = Ramose.Namespace("todo", { title: Ramose.Attr(Schema.String), done: Ramose.Attr(Schema.Boolean), createdAt: Ramose.Attr(Ramose.Instant),});
export const Todos = Ramose.Catalog({ todo: Todo });Ramose.Namespace("todo", …)— a record type, a kind of record like a table (glossary).Ramose.Attr(…)— a field, one named, typed slot on it (glossary).Ramose.Instantis a timestamp.Ramose.Catalog({ todo: Todo })— the schema, your data model as one value; Ramose calls it a catalog (glossary).
Every record type gets an id field for free: Todo.id. This one file is imported by your app, your rules and your deploy — there is no second copy to keep in sync.
Declare the server
Section titled “Declare the server”Ramose runs as one Cloudflare Worker that serves all your databases; Ramose’s code calls it the peer (glossary). You never write that Worker — it ships in ramose/worker. You only name the storage it needs.
import * as Ramose from "ramose";import * as Cloudflare from "alchemy/Cloudflare";
const Store = Cloudflare.R2.Bucket("Store");const Transactor = Cloudflare.DurableObject("TransactorDO", { className: "TransactorDO" });const Replica = Cloudflare.DurableObject("QueryReplicaDO", { className: "QueryReplicaDO" });
export const RamoseWorker = Cloudflare.Worker("Peer", { main: import.meta.resolve("ramose/worker"), compatibility: { date: "2025-06-01", flags: ["nodejs_compat"] }, env: { STORE: Store, TRANSACTOR: Transactor, REPLICA: Replica },});
export const Server = Ramose.Server("Ramose", { worker: RamoseWorker });| Line | What it names |
|---|---|
R2.Bucket("Store") | Object storage: every version of every database is kept here |
DurableObject("TransactorDO") | The writer: commits each database’s writes, in order |
DurableObject("QueryReplicaDO") | Read copies: where queries run |
Worker("Peer") | The Ramose server itself. main resolves the ramose/worker subpath to a file — its code ships with Ramose |
Declare the stack
Section titled “Declare the stack”Alchemy is the TypeScript deploy tool Ramose uses: one file declares your Workers and storage; alchemy dev runs it on your laptop, alchemy deploy ships it to Cloudflare. It finds alchemy.run.ts by name, so keep that filename.
import * as Ramose from "ramose";import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Command from "alchemy/Command";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import { Server } from "./resources.ts";import { Todos } from "./schema.ts";
export const TodosDb = Ramose.Database("todos", { server: Server, catalog: Todos });
export const Ui = Command.Dev( "Ui", Effect.gen(function* () { const server = yield* Server; return { command: "bunx vite --port 5173", env: { VITE_RAMOSE_URL: server.url }, }; }),);
export default Alchemy.Stack( "my-todos", { providers: Layer.mergeAll( Cloudflare.providers(), Ramose.providers(), Command.providers(), ), state: Alchemy.localState(), }, Effect.gen(function* () { const server = yield* Server; yield* TodosDb; const ui = yield* Ui; return { peerUrl: server.url, uiUrl: ui.url }; }),);Ramose.Database("todos", …) installs the schema as a database named todos. Ui starts Vite once the server is answering and hands it the server’s real URL as VITE_RAMOSE_URL, so the two can never disagree about the port.
Connect
Section titled “Connect”import * as Ramose from "ramose/db";import { Todos } from "../schema.ts";
const ramose = Ramose.connect({ url: import.meta.env.VITE_RAMOSE_URL ?? "http://localhost:1337",});
export const db = ramose.db("todos", Todos);No await: the socket opens lazily, so there is nothing to wait for at module scope. No token either — a local server with no policy lets everyone in, which is fine until you share a URL.
/// <reference types="vite/client" />
interface ImportMetaEnv { readonly VITE_RAMOSE_URL?: string;}The query and the writes
Section titled “The query and the writes”import * as Ramose from "ramose/db";import type { Db, Eid } from "ramose/db";import { Todo, type Todos } from "../schema.ts";
export type TodosDb = Db<typeof Todos>;export type TodoEid = Eid<typeof Todos>;
export const todoShape = { id: Todo.id, title: Todo.title, done: Todo.done, createdAt: Todo.createdAt,} as const;
export const todoQuery = Ramose.query(Todo) .orderBy(Todo.createdAt, "asc") .select(todoShape);
/** One row of {@link todoQuery} — inferred, never restated. */export type TodoRow = Ramose.Row<typeof todoQuery>;
export const addTodo = (db: TodosDb, title: string) => db.transact(function* (tx) { const t = yield* tx.entity(); yield* t.add(Todo.title, title); yield* t.add(Todo.done, false); yield* t.add(Todo.createdAt, new Date()); });
export const setDone = (db: TodosDb, eid: TodoEid, done: boolean) => db.transact(function* (tx) { yield* tx.add(eid.id, Todo.done, done); });
export const deleteTodo = (db: TodosDb, eid: TodoEid) => db.transact(function* (tx) { yield* tx.retractEntity(eid.id); });A query is a value — hoist it once and run it once, live, or in the past. TodoRow is read off the query, so adding a field to the shape changes the type everywhere it is used.
Each write is one all-or-nothing change; Ramose calls it a transaction (glossary). tx.entity() starts a record, add sets a field, retractEntity deletes one.
The screen
Section titled “The screen”import { useLive, useTransact } from "ramose/react";import { useState } from "react";import { db } from "./db.ts";import { addTodo, deleteTodo, setDone, todoQuery, type TodoRow } from "./todos.ts";
export const App = () => ( <main> <h1>todos</h1> <NewTodo /> <TodoList /> </main>);
const TodoList = () => { const { rows, error } = useLive(db, todoQuery); if (error !== undefined) return <p>offline…</p>; if (rows === undefined) return <p>loading…</p>; return ( <ul> {rows.map((row) => <TodoRowView key={row.id} row={row} />)} </ul> );};
const TodoRowView = ({ row }: { row: TodoRow }) => { const { run } = useTransact(); return ( <li> <label> <input type="checkbox" checked={row.done} onChange={(e) => void run(setDone(db, { id: row.id }, e.target.checked))} /> <span>{row.title}</span> </label> <button type="button" onClick={() => void run(deleteTodo(db, { id: row.id }))}> delete </button> </li> );};
const NewTodo = () => { const [title, setTitle] = useState(""); const { run } = useTransact(); return ( <form onSubmit={(e) => { e.preventDefault(); if (title.trim() === "") return; void run(addTodo(db, title.trim())); setTitle(""); }} > <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="what needs doing?" /> <button type="submit">add</button> </form> );};useLive(db, todoQuery) is a live query — one that re-runs itself whenever the database changes (glossary). useTransact().run runs a write from an event handler. Notice what is missing: nothing refetches after a write, and nothing invalidates a cache.
import { StrictMode } from "react";import { createRoot } from "react-dom/client";import { App } from "./App.tsx";
const root = document.getElementById("root");if (root === null) throw new Error("no #root in the page");
createRoot(root).render(<StrictMode><App /></StrictMode>);Run it
Section titled “Run it”bun run devA cold start takes a few seconds and ends with Done: 10 succeeded. Four lines are the ones to look for — the Ramose server on :1337, the Worker actually booting, your database installed, and the app on :5173:
[Peer] ready at http://localhost:1337INFO: [Peer] Started in 214ms[todos] created[Ui] ready at http://localhost:5173/Started in NNNms is the line that matters: it means the Worker was bundled and booted, not merely that the port opened. If you see [Peer] ready but never Started in, check main — that is the bare-specifier mistake above, and on this release it fails silently and waits forever.
Open the [Ui] ready at URL — http://localhost:5173 unless something else had the port — and add “buy milk”. Then open a second tab on the same URL: the todo is already there. Tick it in one tab and watch it strike through in the other, in about a second. That is one live query in two tabs; there is no code in this app that makes it happen.
Types are the point of all this, so check them:
bunx tsc --noEmitIf :1337 or :5173 is already busy, both fall back to the next free port and print where they actually landed — read the URLs off the terminal rather than assuming. Anything else, see Troubleshooting.
Add a field
Section titled “Add a field”Four edits, one per layer. Add the field to the record type in schema.ts:
priority: Ramose.Attr(Ramose.Long),Add it to todoShape, and set it in addTodo, both in src/todos.ts:
priority: Todo.priority.optional, // in todoShape
yield* t.add(Todo.priority, 2); // in addTodo, beside the other addsThen render it in TodoRowView, in src/App.tsx, next to the title:
<span>{row.priority ?? "–"}</span>TodoRow gains priority with nothing else to change — the type follows the query. It is .optional because the todos you already added have no priority, and a required field in a shape hides every record that lacks it.
Leave bun run dev running while you make these edits. It watches the stack files, so saving schema.ts re-applies it on its own — the terminal prints Plan: 1 to update and then [todos] updated. If you restart instead, you will see [todos] noop, because the change already landed.
Filter live
Section titled “Filter live”Add a second query in src/todos.ts:
export const openTodos = Ramose.query(Todo) .where(Todo.done.eq(false)) .orderBy(Todo.createdAt, "asc") .select(todoShape);Import it in src/App.tsx alongside todoQuery, then switch TodoList between the two:
const TodoList = () => { const [showAll, setShowAll] = useState(true); const { rows, error } = useLive(db, showAll ? todoQuery : openTodos); if (error !== undefined) return <p>offline…</p>; if (rows === undefined) return <p>loading…</p>; return ( <> <button type="button" onClick={() => setShowAll((v) => !v)}> {showAll ? "show open only" : "show all"} </button> <ul> {rows.map((row) => <TodoRowView key={row.id} row={row} />)} </ul> </> );};Show open todos only, then tick one: it leaves the list on its own. No refetch, no cache invalidation, no subscription to wire up.
Deploy
Section titled “Deploy”bunx alchemy deploy ships this exact stack to your own Cloudflare account — the same files, real credentials instead of the placeholders. Deploy walks through it.