Project Setup
The domain
A task board like Trello or Linear, simplified: lists contain tasks, users collaborate on the same board, and changes by one user should appear instantly for everyone else.
This domain naturally requires real-time: when Alice moves a task to “Done,” Bob should see it move without refreshing.
Create the project
mkdir taskboard-api
cd taskboard-api
npm init -y
npm install @hectoday/http zod srvx better-sqlite3
npm install -D typescript @types/node @types/better-sqlite3 tsx Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"rootDir": "./src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src"]
} Add "type": "module" and a dev script to package.json:
{
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts"
}
} To start the server:
npm run dev The database schema
// src/db.ts
import Database from "better-sqlite3";
const db = new Database("taskboard.db");
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS boards (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL,
name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (board_id) REFERENCES boards(id)
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
assignee_id TEXT,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (list_id) REFERENCES lists(id),
FOREIGN KEY (assignee_id) REFERENCES users(id)
);
`);
export default db; Four tables: users, boards, lists, and tasks. A board has lists (“To Do,” “In Progress,” “Done”), and lists have tasks. This is enough to demonstrate every real-time pattern.
Seed data
const existing = db.prepare("SELECT id FROM users LIMIT 1").get();
if (!existing) {
// Users
db.prepare("INSERT INTO users (id, name, email) VALUES (?, ?, ?)").run(
"user-alice",
"Alice",
"[email protected]",
);
db.prepare("INSERT INTO users (id, name, email) VALUES (?, ?, ?)").run(
"user-bob",
"Bob",
"[email protected]",
);
// Board
db.prepare("INSERT INTO boards (id, name) VALUES (?, ?)").run("board-1", "Sprint 12");
// Lists
db.prepare("INSERT INTO lists (id, board_id, name, position) VALUES (?, ?, ?, ?)").run(
"list-todo",
"board-1",
"To Do",
0,
);
db.prepare("INSERT INTO lists (id, board_id, name, position) VALUES (?, ?, ?, ?)").run(
"list-progress",
"board-1",
"In Progress",
1,
);
db.prepare("INSERT INTO lists (id, board_id, name, position) VALUES (?, ?, ?, ?)").run(
"list-done",
"board-1",
"Done",
2,
);
// Tasks
db.prepare(
"INSERT INTO tasks (id, list_id, title, assignee_id, position) VALUES (?, ?, ?, ?, ?)",
).run("task-1", "list-todo", "Design login page", "user-alice", 0);
db.prepare(
"INSERT INTO tasks (id, list_id, title, assignee_id, position) VALUES (?, ?, ?, ?, ?)",
).run("task-2", "list-todo", "Set up CI pipeline", "user-bob", 1);
db.prepare(
"INSERT INTO tasks (id, list_id, title, assignee_id, position) VALUES (?, ?, ?, ?, ?)",
).run("task-3", "list-progress", "Build API endpoints", "user-alice", 0);
db.prepare(
"INSERT INTO tasks (id, list_id, title, assignee_id, position) VALUES (?, ?, ?, ?, ?)",
).run("task-4", "list-done", "Write project brief", "user-bob", 0);
} REST endpoints
Basic CRUD for the task board (from the REST course patterns):
// src/routes/tasks.ts
import * as z from "zod/v4";
import { route, group } from "@hectoday/http";
import db from "../db.js";
const CreateTaskBody = z.object({
title: z.string().min(1).max(500),
description: z.string().optional(),
listId: z.string().min(1),
assigneeId: z.string().optional(),
});
const MoveTaskBody = z.object({
listId: z.string().min(1),
position: z.number().int().min(0),
});
export const taskRoutes = group([
route.get("/boards/:boardId/tasks", {
resolve: (c) => {
const tasks = db
.prepare(
"SELECT t.* FROM tasks t JOIN lists l ON t.list_id = l.id WHERE l.board_id = ? ORDER BY l.position, t.position",
)
.all(c.params.boardId);
return Response.json({ data: tasks });
},
}),
route.post("/tasks", {
request: { body: CreateTaskBody },
resolve: (c) => {
if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
const { title, description, listId, assigneeId } = c.input.body;
const id = crypto.randomUUID();
db.prepare(
"INSERT INTO tasks (id, list_id, title, description, assignee_id, position) VALUES (?, ?, ?, ?, ?, ?)",
).run(id, listId, title, description ?? null, assigneeId ?? null, 0);
const task = db.prepare("SELECT * FROM tasks WHERE id = ?").get(id);
return Response.json(task, { status: 201 });
},
}),
route.patch("/tasks/:id/move", {
request: { body: MoveTaskBody },
resolve: (c) => {
if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
const { listId, position } = c.input.body;
const result = db
.prepare(
"UPDATE tasks SET list_id = ?, position = ?, updated_at = datetime('now') WHERE id = ?",
)
.run(listId, position, c.params.id);
if (result.changes === 0) return Response.json({ error: "Task not found" }, { status: 404 });
const task = db.prepare("SELECT * FROM tasks WHERE id = ?").get(c.params.id);
return Response.json(task);
},
}),
route.delete("/tasks/:id", {
resolve: (c) => {
const result = db.prepare("DELETE FROM tasks WHERE id = ?").run(c.params.id);
if (result.changes === 0) return Response.json({ error: "Task not found" }, { status: 404 });
return new Response(null, { status: 204 });
},
}),
]); // src/app.ts
import { setup, route } from "@hectoday/http";
import { taskRoutes } from "./routes/tasks.js";
export const app = setup({
routes: [route.get("/health", { resolve: () => Response.json({ status: "ok" }) }), ...taskRoutes],
}); // src/server.ts
import { serve } from "srvx";
import { app } from "./app.js";
serve({ fetch: app.fetch, port: 3000 }); These endpoints are standard REST. In the next lessons, we add real-time: when Alice creates a task or moves one, Bob sees it instantly.
Exercises
Exercise 1: Start the server and list tasks: curl http://localhost:3000/boards/board-1/tasks.
Exercise 2: Create a task and move it to a different list. Verify the update.
Exercise 3: Think about which operations should trigger real-time updates: task created, task moved, task deleted, task updated. (Answer: all of them.)
Why is a task board a good domain for learning real-time APIs?