Admin CRUD Interface

Website Rebuild with Astro

Chapter 10 · Admin CRUD Interface

Django has a free admin. Laravel and Rails don't, but each has some code-generation help — Laravel's make:model -mcr, Rails' own rails generate scaffold. Astro has neither. This is genuinely the most minimal starting point of all five siblings.

The Parent Picker

The same searchable flat list every sibling course reached for — every other page's own fullPath, filterable client-side, rather than a nested tree widget, for the same scale-justified reason established since the Next.js rebuild's own Chapter 10.

Paying Off Chapter 2's Deferred Cost

// lib/pages.ts export async function movePage(pageId: number, newParentId: number | null) { const [page] = await db.select().from(pages).where(eq(pages.id, pageId)); const newFullPath = await computeFullPath(page.slug, newParentId); await db.update(pages) .set({ parentId: newParentId, fullPath: newFullPath }) .where(eq(pages.id, pageId)); await updateDescendantPaths(pageId); } async function updateDescendantPaths(pageId: number) { const [parent] = await db.select().from(pages).where(eq(pages.id, pageId)); const children = await db.select().from(pages).where(eq(pages.parentId, pageId)); for (const child of children) { const newPath = `${parent.fullPath}/${child.slug}`; await db.update(pages).set({ fullPath: newPath }).where(eq(pages.id, child.id)); await updateDescendantPaths(child.id); } }

Chapter 2 already established that Drizzle has no lifecycle hooks — so updateDescendantPaths is a fully explicit recursive walk, no different in spirit from Laravel's own updateDescendantPaths() or Rails' own update_descendant_paths!, just written without any framework callback machinery underneath it at all.

Delete Refusal — a Precise Callback to Two Earlier Findings

// src/pages/api/pages/[id].ts export const DELETE: APIRoute = async ({ params }) => { try { await db.delete(pages).where(eq(pages.id, Number(params.id))); return new Response(JSON.stringify({ status: 'ok' })); } catch (err) { return new Response( JSON.stringify({ error: "Can't delete a page that still has children — move or delete them first." }), { status: 409 } ); } };
The same layer as Laravel — needing the same ceremony
Chapter 2 verified onDelete: 'restrict' as a genuine database-level constraint, the same layer Laravel's own restrictOnDelete() operates at. That means deleting a page with children here raises a real, raw database exception — requiring the same try/catch ceremony Laravel's own Chapter 10 needed, not the smoother built-in error-population Rails found for itself.
A precise correction, not a broad rule
It would be tempting to conclude "database-level protection needs a try/catch, application-level protection doesn't" — but that's not quite right either. Django's own PROTECT is application-level, yet it still raises a real ProtectedError exception that needs its own try/except. Rails' own smoother, non-exception path — destroy simply returning false with errors already populated — was a specific design choice in ActiveRecord's own API, not a trait every application-level protection shares. Astro needs a try/catch for the same reason Laravel and Django both effectively do, each for their own reason.

Five Admin Interfaces, Compared

DjangoLaravelRailsAstro
Free admin?Yes — live, runtime-introspectedNoNoNo
Scaffold generator?N/A — has the admin insteadmake:model -mcrrails generate scaffoldNone at all
Delete-with-children handlingCaught ProtectedErrorCaught QueryExceptionReturns false, no exceptionCaught raw database exception

Hands-On Exercises

Exercise 1

Build the searchable flat parent picker for the admin interface, excluding the page itself from its own candidate-parent list.

📄 View solution
Exercise 2

Build movePage() and updateDescendantPaths(), and confirm a real reparent operation correctly cascades full_path updates to every descendant, not just the moved page itself.

📄 View solution
Exercise 3

Attempt to delete a page with children via the DELETE endpoint, confirm the raw database constraint throws, and confirm the try/catch turns it into a real 409 response with a readable message.

📄 View solution

Chapter 10 Quick Reference

  • No free admin, no scaffold generator — the most minimal starting point of all five siblings
  • movePage() / updateDescendantPaths() — fully explicit, since Drizzle has no lifecycle hooks
  • Delete refusal needs a real try/catch — the same ceremony as Laravel, and precisely, as Django too
  • A corrected rule — Rails' own smooth path was ActiveRecord's own API design, not a universal application-level trait
  • Next chapter: Deployment