Skip to main content

Tutorial: a real-time todo app

This tutorial builds a complete app end to end: authenticated users with private todos, categories and tags (relations), image uploads, full-text search, and a React UI that updates in real time with optimistic mutations. It mirrors the project under example/ in the repo.

By the end you'll have used: resources, auth scopes, relations, lifecycle hooks, file storage, search, live subscriptions, and live aggregations.

1. Schema

// src/db/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";

export const usersTable = sqliteTable("users", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
name: text("name").notNull(),
passwordHash: text("passwordHash").notNull(),
createdAt: integer("createdAt", { mode: "timestamp" }).notNull(),
});

export const categoriesTable = sqliteTable("categories", {
id: text("id").primaryKey(),
userId: text("userId").notNull(),
name: text("name").notNull(),
color: text("color"),
createdAt: integer("createdAt", { mode: "timestamp" }).notNull(),
});

export const todosTable = sqliteTable("todos", {
id: text("id").primaryKey(),
userId: text("userId").notNull(),
title: text("title").notNull(),
description: text("description"),
completed: integer("completed", { mode: "boolean" }).default(false),
position: integer("position").notNull(),
categoryId: text("categoryId"),
imageId: text("imageId"),
createdAt: integer("createdAt", { mode: "timestamp" }).notNull(),
updatedAt: integer("updatedAt", { mode: "timestamp" }).notNull(),
});

export const tagsTable = sqliteTable("tags", {
id: text("id").primaryKey(),
userId: text("userId").notNull(),
name: text("name").notNull(),
});

export const todoTagsTable = sqliteTable("todo_tags", {
todoId: text("todoId").notNull(),
tagId: text("tagId").notNull(),
});

export const filesTable = sqliteTable("files", {
id: text("id").primaryKey(),
userId: text("userId").notNull(),
key: text("key").notNull(),
filename: text("filename").notNull(),
contentType: text("contentType").notNull(),
size: integer("size").notNull(),
createdAt: integer("createdAt", { mode: "timestamp" }).notNull(),
});

2. Database

// src/db/db.ts
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
import { env } from "../env"; // your createEnv schema — see Environment variables

const client = createClient({ url: env.DB_FILE_NAME });
export const db = drizzle(client);

Push the schema with drizzle-kit: npx drizzle-kit push.

3. Authentication

We use the Passport adapter with a session store, plus useAuth to mount /login, /signup, /logout, and /me.

// src/auth.ts
import { eq } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import {
useAuth,
createPassportAdapter,
hashPassword,
verifyPassword,
ValidationError,
} from "covara";
import { db } from "./db/db";
import { usersTable } from "./db/schema";

const authAdapter = createPassportAdapter({
getUserById: async (id) => {
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, id)).limit(1);
return user ?? null;
},
});

export const auth = useAuth({
adapter: authAdapter,
login: {
validateCredentials: async (email, password) => {
const [user] = await db.select().from(usersTable).where(eq(usersTable.email, email)).limit(1);
if (!user || !(await verifyPassword(password, user.passwordHash))) return null;
return { id: user.id, email: user.email, name: user.name };
},
},
signup: {
createUser: async ({ email, password, name }) => {
const existing = await db.select().from(usersTable).where(eq(usersTable.email, email)).limit(1);
if (existing.length > 0) throw new ValidationError("Email already registered");
const [user] = await db
.insert(usersTable)
.values({
id: randomUUID(),
email,
name: name ?? "User",
passwordHash: await hashPassword(password),
createdAt: new Date(),
})
.returning();
return { id: user.id, email: user.email, name: user.name };
},
},
});

hashPassword/verifyPassword are Covara's built-in scrypt helpers — see Passwords.

4. Resources

Now the heart of the app. Every resource scopes reads/writes to the current user with RSQL scopes, and todos declares relations to categories, files, and tags.

// src/main.ts
import { randomUUID } from "node:crypto";
import { eq, max } from "drizzle-orm";
import {
createCovara,
rsql,
UnauthorizedError,
initializeKV,
initializeStorage,
useFileResource,
} from "covara";
import { startServer } from "covara/node";
import { db } from "./db/db";
import { auth } from "./auth";
import {
usersTable, todosTable, categoriesTable, tagsTable, todoTagsTable, filesTable,
} from "./db/schema";

await initializeKV({ type: "memory", prefix: "todo-app" });
initializeStorage({ type: "local", local: { basePath: "./.tmp/uploads", baseUrl: "/uploads" } });

const app = createCovara({ auth, cors: true })
.resource("/categories", categoriesTable, {
id: categoriesTable.id,
db,
auth: {
read: async (user) => rsql`userId==${user?.id}`,
create: async (user) => (user ? rsql`*` : rsql``),
update: async (user) => rsql`userId==${user?.id}`,
delete: async (user) => rsql`userId==${user?.id}`,
},
generatedFields: [categoriesTable.id, categoriesTable.userId, categoriesTable.createdAt],
hooks: {
onBeforeCreate: async (ctx, data) => {
if (!ctx.user) throw new UnauthorizedError("Must be logged in");
return { ...data, id: randomUUID(), userId: ctx.user.id, createdAt: new Date() };
},
},
})
.resource("/tags", tagsTable, {
id: tagsTable.id,
db,
auth: {
read: async (user) => rsql`userId==${user?.id}`,
create: async (user) => (user ? rsql`*` : rsql``),
update: async (user) => rsql`userId==${user?.id}`,
delete: async (user) => rsql`userId==${user?.id}`,
},
generatedFields: [tagsTable.id, tagsTable.userId],
hooks: {
onBeforeCreate: async (ctx, data) => {
if (!ctx.user) throw new UnauthorizedError("Must be logged in");
return { ...data, id: randomUUID(), userId: ctx.user.id };
},
},
})
.resource("/todos", todosTable, {
id: todosTable.id,
db,
pagination: { defaultLimit: 100, maxLimit: 500 },
search: {
enabled: true,
fields: { title: { weight: 2.0 }, description: { weight: 1.0 } },
},
auth: {
read: async (user) => rsql`userId==${user?.id}`,
create: async (user) => (user ? rsql`*` : rsql``),
update: async (user) => rsql`userId==${user?.id}`,
delete: async (user) => rsql`userId==${user?.id}`,
subscribe: async (user) => rsql`userId==${user?.id}`,
},
generatedFields: [todosTable.id, todosTable.userId, todosTable.position, todosTable.createdAt, todosTable.updatedAt],
relations: {
category: {
resource: "categories", schema: categoriesTable, type: "belongsTo",
foreignKey: todosTable.categoryId, references: categoriesTable.id,
},
image: {
resource: "files", schema: filesTable, type: "belongsTo",
foreignKey: todosTable.imageId, references: filesTable.id,
},
tags: {
resource: "tags", schema: tagsTable, type: "manyToMany",
foreignKey: todosTable.id, references: tagsTable.id,
through: { schema: todoTagsTable, sourceKey: todoTagsTable.todoId, targetKey: todoTagsTable.tagId },
},
},
hooks: {
onBeforeCreate: async (ctx, data) => {
if (!ctx.user) throw new UnauthorizedError("Must be logged in");
const [maxPos] = await db
.select({ max: max(todosTable.position) })
.from(todosTable)
.where(eq(todosTable.userId, ctx.user.id));
return {
...data, id: randomUUID(), userId: ctx.user.id,
position: (maxPos?.max ?? -1) + 1, createdAt: new Date(), updatedAt: new Date(),
};
},
onBeforeUpdate: async (_ctx, _id, data) => ({ ...data, updatedAt: new Date() }),
},
});

A few things worth noting:

  • generatedFields marks columns the server fills in (id, userId, timestamps), so the client never has to send them and they are stripped from inbound payloads.
  • onBeforeCreate is where ownership is stamped — ctx.user.id is the authenticated user from the session middleware.
  • auth.subscribe scopes the SSE stream, so a user only sees real-time changes to their own todos.
  • search registers title/description as searchable; the actual index depends on the search adapter you configure.

5. File uploads

Mount a file resource. It generates upload/download/list/delete endpoints with MIME and size validation.

app.route(
"/api/files",
useFileResource(filesTable, {
db,
schema: filesTable,
id: filesTable.id,
allowedMimeTypes: ["image/jpeg", "image/png", "image/gif", "image/webp"],
maxFileSize: 5 * 1024 * 1024,
auth: {
read: async (user) => rsql`userId==${user?.id}`,
create: async (user) => (user ? rsql`*` : rsql``),
delete: async (user) => rsql`userId==${user?.id}`,
},
})
);

await startServer(app, { port: 3000 });

6. The React client

// frontend/src/App.tsx
import { getOrCreateClient } from "covara/client";
import {
useAuth, useLiveList, useLiveAggregate, useSearch, useFileUpload,
} from "covara/client/react";

const client = getOrCreateClient({
baseUrl: location.origin,
credentials: "include",
offline: true,
});

function TodoApp() {
// Live, paginated list with relations included — updates in real time.
const { items: todos, status, mutate, hasMore, loadMore } = useLiveList<Todo>("/api/todos", {
orderBy: "position",
include: ["category", "image", "tags"],
limit: 5,
});

// Live aggregation across ALL todos (not just the loaded page),
// recomputed on the server on every change.
const { groups } = useLiveAggregate("/api/todos", { groupBy: ["completed"], count: true });
const completed = groups.find((g) => g.key?.completed === true)?.count ?? 0;

const addTodo = (title: string) => mutate.create({ title }); // optimistic
const toggle = (t: Todo) => mutate.update(t.id, { completed: !t.completed });

return (
<ul>
{todos.map((t) => (
<li key={t.id}>
<input type="checkbox" checked={t.completed} onChange={() => toggle(t)} />
{t.title} {t.category && <em>{t.category.name}</em>}
<button onClick={() => mutate.delete(t.id)}>×</button>
</li>
))}
{hasMore && <button onClick={loadMore}>Load more</button>}
</ul>
);
}

mutate.create/update/delete apply optimistically (instant UI), queue while offline, and reconcile against the server response. Every change streams to all connected clients via the subscription. See Live queries and Offline.

Type safety end to end

Generate types from the running API and wrap the client to get fully-typed resource accessors and a fluent query builder:

npx covara types --url http://localhost:3000 --out src/generated/api-types.ts
import { createTypedClient } from "./generated/api-types";
const typed = createTypedClient(client);

const { items } = useLiveList(
typed.resources.todos.orderBy("position").include("category", "tags").limit(5)
); // items is fully typed, including the included relations

See Type generation.

Where to go next