Admin CRUD Interface

Website Rebuild with Express

Chapter 10 · Admin CRUD Interface

No free admin, no scaffold generator — the same most-minimal starting point Astro already reached. Everything here is hand-built, including the one place this course's own recursive SQL knowledge pays off a second time.

The Parent Picker

The same searchable flat list every sibling course reached for — every other page's own full_path, filterable client-side, excluding the page being edited from its own candidate-parent list.

Reparenting: Finding Every Descendant in One Query

// lib/pages.js async function movePage(pool, pageId, newParentId) { const [pageRows] = await pool.query('SELECT * FROM pages WHERE id = ?', [pageId]); const page = pageRows[0]; const newFullPath = await computeFullPath(pool, page.slug, newParentId); await pool.query('UPDATE pages SET parent_id = ?, full_path = ? WHERE id = ?', [newParentId, newFullPath, pageId]); // Every descendant, found in ONE recursive query, ordered parent-before-child const [descendants] = await pool.query(` WITH RECURSIVE descendants AS ( SELECT *, 0 AS depth FROM pages WHERE id = ? UNION ALL SELECT p.*, d.depth + 1 FROM pages p INNER JOIN descendants d ON p.parent_id = d.id ) SELECT * FROM descendants WHERE id != ? ORDER BY depth ASC; `, [pageId, pageId]); const pathById = { [pageId]: newFullPath }; for (const descendant of descendants) { const parentPath = pathById[descendant.parent_id]; const newDescendantPath = `${parentPath}/${descendant.slug}`; await pool.query('UPDATE pages SET full_path = ? WHERE id = ?', [newDescendantPath, descendant.id]); pathById[descendant.id] = newDescendantPath; } }
A real, verified improvement — precisely scoped
Every sibling course's own cascade logic queried children level by level, walking down one database round-trip per level (Laravel's updateDescendantPaths(), Rails' update_descendant_paths!, Astro's own version). Here, Chapter 6's own recursive CTE finds every affected descendant, at any depth, in a single query — ORDER BY depth ASC means the results already arrive parent-before-child, so a single linear for loop, not a recursive function, is enough to process them in the correct dependency order.
Still honestly sequential where it has to be
The discovery step is genuinely one query instead of many. The write step is not — each descendant's own new full_path depends on its own parent's already-computed new path, so writing them still takes one UPDATE per row, run in order. This is a real, partial improvement, not a magic bullet: the recursive CTE removed the N+1 read pattern, not the N-write pattern, which is fundamentally unavoidable here regardless of ORM, framework, or query strategy.

Delete Refusal — the Same Ceremony as Laravel and Astro

app.delete('/api/pages/:id', requireAuth, async (req, res) => { try { await pool.query('DELETE FROM pages WHERE id = ?', [req.params.id]); res.json({ status: 'ok' }); } catch (err) { res.status(409).json({ error: "Can't delete a page that still has children — move or delete them first." }); } });

Chapter 2 verified ON DELETE RESTRICT as the cleanest, most unambiguous database-level constraint in the series. That means the same try/catch ceremony Laravel and Astro both needed — not Rails' own smoother, non-exception path, which was ActiveRecord's own specific API design, not a universal trait.

Six Admin Interfaces, Compared One Final Time

DjangoLaravelRailsAstroExpress
Free admin / scaffold?Live adminNoneScaffold generatorNeitherNeither
Descendant discoveryImplicit save() cascadeLevel-by-level recursionLevel-by-level recursionLevel-by-level recursionOne recursive CTE, all levels at once
Delete-with-children handlingCaught ProtectedErrorCaught QueryExceptionReturns false, no exceptionCaught raw exceptionCaught raw 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() using the recursive CTE for descendant discovery, and confirm a real multi-level reparent operation correctly cascades full_path updates to every descendant in the right order.

📄 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 same most-minimal starting point as Astro
  • Recursive CTE descendant discovery — one query finds every affected row, at any depth, in dependency order
  • The write step stays sequential — a real, honestly-scoped limitation, not solved by the same query
  • Same delete-refusal ceremony as Laravel and Astro — a real try/catch needed, unlike Rails' own smoother path
  • Next chapter: Deployment