Skip to content

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.

Terminal window
mkdir my-todos && cd my-todos
bun add ramose react react-dom
bun add -d vite @vitejs/plugin-react typescript @types/react @types/react-dom

One 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:

.env
CI=1
CLOUDFLARE_ACCOUNT_ID=0123456789abcdef0123456789abcdef
CLOUDFLARE_API_TOKEN=x

CI=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:

.gitignore
node_modules/
.alchemy/
.env

bun 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:

package.json — merge these in, keep dependencies
"type": "module",
"scripts": { "dev": "alchemy dev" },

Three small files finish the setup:

tsconfig.json
{
"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"]
}
}
vite.config.ts
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [react()] });
index.html
<!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>
schema.ts
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.Instant is 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.

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.

resources.ts
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 });
LineWhat 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

Why these three things →

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.

alchemy.run.ts
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.

src/db.ts
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.

src/vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_RAMOSE_URL?: string;
}
src/todos.ts
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.

src/App.tsx
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.

src/main.tsx
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>);
Terminal window
bun run dev

A 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:

Terminal window
[Peer] ready at http://localhost:1337
INFO: [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:

Terminal window
bunx tsc --noEmit

If :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.

Four edits, one per layer. Add the field to the record type in schema.ts:

schema.ts — inside Ramose.Namespace("todo", { … })
priority: Ramose.Attr(Ramose.Long),

Add it to todoShape, and set it in addTodo, both in src/todos.ts:

src/todos.ts — one line into todoShape, one into addTodo
priority: Todo.priority.optional, // in todoShape
yield* t.add(Todo.priority, 2); // in addTodo, beside the other adds

Then render it in TodoRowView, in src/App.tsx, next to the title:

src/App.tsx — inside TodoRowView
<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.

Add a second query in src/todos.ts:

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:

src/App.tsx — TodoList
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.

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.