Directing the Fastify Server and First CRUD Resource
Objectives
By the end of this chapter, you should be able to:
- Direct an AI assistant to stand up a Fastify server with env config, CORS, a health check, and graceful shutdown
- Get an AI assistant to establish a route → repo layering pattern, and verify it actually respects that pattern instead of just naming files that suggest it does
- Review a full CRUD resource (watch regions) for the one write path that needs a real database transaction, not two statements that happen to usually run in order
💡 Why this matters: Every resource added from here on, in this course and in a real app, flows through whatever pattern gets established in this lesson. If you let an AI assistant get sloppy about the route → repo boundary here, you won’t notice for weeks, and by then a dozen routes have quietly inlined their own queries.
Why the Repo Layer Is the Thing to Watch
An AI assistant asked for “a Fastify CRUD endpoint for watch regions” will happily produce something that works on the first request. What it might not do, unless you say so directly, is keep every Drizzle table access behind a repo function. It’s completely plausible for a model to write db.select().from(watchRegions).where(...) straight inside a route handler, because that’s shorter, it’s a common pattern in a lot of training data, and it answers the prompt “make this endpoint work” just fine. The problem shows up later: the entire reason for a repo layer is that a repo function’s internals can grow real logic, like wrapping two writes in a transaction, without the route file that calls it ever changing. A route that queries the table directly can’t get that upgrade for free. It has to be rewritten, and if you don’t notice it needs rewriting, it just doesn’t get the transaction.
So the prompt below states the layering rule explicitly, the same way the schema lesson stated database invariants explicitly: don’t assume the AI will infer an architectural convention from a request that would work fine without it.
The Prompt
What It Built
// apps/server/src/env.ts
import "dotenv/config";
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing required environment variable: ${name}`);
return value;
}
export const env = {
DATABASE_URL: required("DATABASE_URL"),
SERVER_PORT: Number(process.env.SERVER_PORT ?? 4000),
POLL_INTERVAL_MS: Number(process.env.POLL_INTERVAL_MS ?? 15000),
WEB_ORIGIN: process.env.WEB_ORIGIN ?? "http://localhost:3000",
};
// apps/server/src/index.ts
import Fastify from "fastify";
import cors from "@fastify/cors";
import { env } from "./env.js";
import { registerApiRoutes } from "./routes/index.js";
async function main() {
const app = Fastify({ logger: true });
await app.register(cors, { origin: env.WEB_ORIGIN });
app.get("/health", async () => ({ ok: true, ts: Date.now() }));
await registerApiRoutes(app);
const shutdown = async (signal: string) => {
app.log.info(`received ${signal}, shutting down...`);
await app.close();
process.exit(0);
};
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
await app.listen({ port: env.SERVER_PORT, host: "0.0.0.0" });
}
main().catch((err) => {
console.error("Fatal startup error:", err);
process.exit(1);
});
// apps/server/src/routes/index.ts
import type { FastifyInstance } from "fastify";
import { registerWatchRegionRoutes } from "./watchRegions.js";
export async function registerApiRoutes(app: FastifyInstance): Promise<void> {
await app.register(
async (api) => {
registerWatchRegionRoutes(api);
},
{ prefix: "/api" }
);
}
// packages/shared/src/watchRegion.ts
export interface WatchRegion {
id: number;
name: string;
lat: number;
lon: number;
radiusNm: number;
enabled: boolean;
isDefault: boolean;
createdAt: string;
}
// apps/server/src/db/repos/watchRegionsRepo.ts
import { asc, eq } from "drizzle-orm";
import type { WatchRegion } from "@skywatch/shared";
import { db } from "../client.js";
import { watchRegions } from "../schema.js";
function toApi(row: typeof watchRegions.$inferSelect): WatchRegion {
return {
id: row.id,
name: row.name,
lat: row.lat,
lon: row.lon,
radiusNm: row.radiusNm,
enabled: row.enabled,
isDefault: row.isDefault,
createdAt: row.createdAt.toISOString(),
};
}
export async function listWatchRegions(): Promise<WatchRegion[]> {
const rows = await db.select().from(watchRegions).orderBy(watchRegions.createdAt);
return rows.map(toApi);
}
export async function createWatchRegion(input: {
name: string;
lat: number;
lon: number;
radiusNm: number;
}): Promise<WatchRegion> {
const existing = await db.select({ id: watchRegions.id }).from(watchRegions).limit(1);
const isFirstRegion = existing.length === 0;
const [row] = await db
.insert(watchRegions)
.values({ ...input, isDefault: isFirstRegion })
.returning();
return toApi(row);
}
export async function setWatchRegionEnabled(id: number, enabled: boolean): Promise<WatchRegion | null> {
const [row] = await db.update(watchRegions).set({ enabled }).where(eq(watchRegions.id, id)).returning();
return row ? toApi(row) : null;
}
export async function setWatchRegionDefault(id: number): Promise<WatchRegion | null> {
return db.transaction(async (tx) => {
await tx.update(watchRegions).set({ isDefault: false }).where(eq(watchRegions.isDefault, true));
const [row] = await tx
.update(watchRegions)
.set({ isDefault: true, enabled: true })
.where(eq(watchRegions.id, id))
.returning();
return row ? toApi(row) : null;
});
}
export async function deleteWatchRegion(id: number): Promise<boolean> {
return db.transaction(async (tx) => {
const [deleted] = await tx.delete(watchRegions).where(eq(watchRegions.id, id)).returning();
if (!deleted) return false;
if (deleted.isDefault) {
const [oldest] = await tx
.select({ id: watchRegions.id })
.from(watchRegions)
.orderBy(asc(watchRegions.createdAt))
.limit(1);
if (oldest) {
await tx.update(watchRegions).set({ isDefault: true }).where(eq(watchRegions.id, oldest.id));
}
}
return true;
});
}
// apps/server/src/routes/watchRegions.ts
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import {
createWatchRegion,
deleteWatchRegion,
listWatchRegions,
setWatchRegionDefault,
setWatchRegionEnabled,
} from "../db/repos/watchRegionsRepo.js";
const postBodySchema = z.object({
name: z.string().trim().min(1).max(80),
lat: z.number().min(-90).max(90),
lon: z.number().min(-180).max(180),
radiusNm: z.number().min(1).max(250).default(200),
});
const idParamsSchema = z.object({ id: z.coerce.number().int().positive() });
const patchBodySchema = z.object({ enabled: z.boolean() });
export function registerWatchRegionRoutes(app: FastifyInstance): void {
app.get("/watch-regions", async () => {
const regions = await listWatchRegions();
return { regions };
});
app.post("/watch-regions", async (req, reply) => {
const parsed = postBodySchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ error: "invalid watch region", issues: parsed.error.issues });
}
const region = await createWatchRegion(parsed.data);
return reply.status(201).send({ region });
});
app.patch("/watch-regions/:id", async (req, reply) => {
const params = idParamsSchema.safeParse(req.params);
const body = patchBodySchema.safeParse(req.body);
if (!params.success || !body.success) return reply.status(400).send({ error: "invalid request" });
const region = await setWatchRegionEnabled(params.data.id, body.data.enabled);
if (!region) return reply.status(404).send({ error: "not found" });
return { region };
});
app.delete("/watch-regions/:id", async (req, reply) => {
const parsed = idParamsSchema.safeParse(req.params);
if (!parsed.success) return reply.status(400).send({ error: "invalid id" });
const deleted = await deleteWatchRegion(parsed.data.id);
if (!deleted) return reply.status(404).send({ error: "not found" });
return reply.status(204).send();
});
app.post("/watch-regions/:id/default", async (req, reply) => {
const parsed = idParamsSchema.safeParse(req.params);
if (!parsed.success) return reply.status(400).send({ error: "invalid id" });
const region = await setWatchRegionDefault(parsed.data.id);
if (!region) return reply.status(404).send({ error: "not found" });
return { region };
});
}
Review This
Did every route actually go through the repo, or did one sneak in a direct query? This is the single most important thing to check in this whole lesson, and it’s easy to miss because a route that inlines db.select().from(watchRegions).where(eq(watchRegions.id, id)) works exactly as well as one that calls listWatchRegions(). Nothing fails, nothing looks wrong in a manual test. Read every handler in watchRegions.ts and confirm the only things it imports from ../db/ are functions from watchRegionsRepo.ts, never schema.js or client.js directly. If you find a direct query, the fix is: “the GET/PATCH handler for watch-regions queries the table directly, move that query into watchRegionsRepo.ts as a named function and call it from the route instead.”
Is setWatchRegionDefault actually wrapped in db.transaction, or is it two awaits in a row? Two sequential db.update() calls with no transaction will pass every happy-path test you throw at them, because in a quick manual check nothing ever crashes between the two writes. The bug only shows up under real conditions: a process restart, a thrown error, or a second concurrent request landing between “clear the old default” and “set the new one.” Open setWatchRegionDefault and confirm both writes happen against a tx parameter inside a db.transaction(async (tx) => {...}) callback, not against db directly. If they’re two bare await db.update(...) calls, the fix is: “setWatchRegionDefault needs to wrap both writes in a single db.transaction so they commit or fail together, not run as two independent statements.”
Does deleteWatchRegion actually re-promote a new default, or does it just delete the row? This one is easy for an AI to skip entirely, because “delete a region” sounds complete once the row is gone, and the requirement to backfill a new default only applies in the specific case where the deleted region happened to be the default one. A version that just runs db.delete(watchRegions).where(eq(watchRegions.id, id)) will work fine for every non-default region you delete in testing, and only reveal the gap the one time you delete the actual default and the app is left with zero default regions. Check for the if (deleted.isDefault) branch and the follow-up select for the oldest remaining region. If it’s missing, the fix is: “deleteWatchRegion needs to check whether the deleted region was the default, and if so promote the oldest remaining region to default, wrapped in the same transaction as the delete.”
Try It
- Run the prompt above against your AI coding assistant, with the Module 1 monorepo and schema already in place.
- Read every file it produced against the three checks above before running anything.
- Start the server with
npm run dev:server, thencurl http://localhost:4000/healthand confirm a 200. - Create a region:
Confirm the response comes back
curl -X POST http://localhost:4000/api/watch-regions \ -H "Content-Type: application/json" \ -d '{"name":"Test Region","lat":40.6413,"lon":-73.7781,"radiusNm":100}'isDefault: truewith no separate call needed, since it’s the first region. - Create a second region the same way and confirm it comes back
isDefault: false. POST /api/watch-regions/:id/defaultagainst the second region’s id, thenGET /api/watch-regionsand confirm exactly one region hasisDefault: true, the second one.- POST with
lat: 200and confirm a 400 with Zod’s validation detail, not a raw database error. - Delete the current default region, then
GET /api/watch-regionsand confirm some other region picked upisDefault: trueautomatically, not none of them.
Recap
- The route → repo boundary is the thing most worth checking by hand in AI-generated backend code, because a direct query in a route handler works fine right up until the day a repo function underneath it needs to grow real logic.
setWatchRegionDefaultanddeleteWatchRegionare the two write paths that need an actualdb.transaction, not two statements that happen to usually run in order. Both look identical to their transactional versions in a quick manual test.- None of these three checks show up as a crash on a fresh install. They only show up under a restart, a concurrent request, or the specific delete-the-default case, which is exactly why Try It ends by deliberately deleting the default region.
Next lesson: directing the poller and the edge-triggered detection engine that watches its output for emergency squawks and overflights.