xtablo-source/.planning/phases/03-tablos-crud/03-PATTERNS.md
Arthur Belleville f53b54637b
docs(03): plan phase 3 — Tablos CRUD (3 plans, 3 waves)
Plans cover TABLO-01..06 via MVP vertical slices: foundation (migration
+ sqlc + test scaffold + button CSS), list+create (dashboard, inline
form, OOB swap), and detail+edit+delete (ownership 404, inline edit
fragments, inline confirm delete). Includes Nyquist VALIDATION.md and
PATTERNS.md with real analog excerpts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:08:08 +02:00

21 KiB

Phase 3: Tablos CRUD - Pattern Map

Mapped: 2026-05-14 Files analyzed: 9 new/modified files Analogs found: 9 / 9


File Classification

New/Modified File Role Data Flow Closest Analog Match Quality
backend/migrations/0003_tablos.sql migration batch backend/migrations/0002_auth.sql exact
backend/internal/db/queries/tablos.sql query CRUD backend/internal/db/queries/users.sql role-match
backend/internal/web/handlers_tablos.go handler request-response backend/internal/web/handlers_auth.go exact
backend/templates/tablos.templ component request-response backend/templates/auth_signup.templ + backend/templates/index.templ exact
backend/internal/web/router.go config request-response backend/internal/web/router.go (self — modify) self
backend/cmd/web/main.go config request-response backend/cmd/web/main.go (self — modify) self
backend/templates/layout.templ component request-response backend/templates/layout.templ (self — modify footer) self
backend/templates/index.templ component request-response backend/templates/index.templ (self — delete or empty) self
backend/internal/web/ui/button.css config backend/internal/web/ui/button.css (self — add variants) self

Pattern Assignments

backend/migrations/0003_tablos.sql (migration, batch)

Analog: backend/migrations/0002_auth.sql

Header comment and goose Up/Down pattern (lines 1-30 of analog):

-- migrations/0002_auth.sql
-- Phase 2: Authentication — users + sessions tables.

-- +goose Up
CREATE TABLE users (
    id            uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
    email         citext      NOT NULL UNIQUE,
    password_hash text        NOT NULL,
    created_at    timestamptz NOT NULL DEFAULT now(),
    updated_at    timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX sessions_user_id_idx   ON sessions(user_id);

-- +goose Down
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;
-- citext + pgcrypto left in place — extensions are cheap and shared across migrations.

Copy conventions:

  • Header comment: -- migrations/NNNN_name.sql then -- Phase N: Description
  • UUID PKs via DEFAULT gen_random_uuid() (no import needed — pgcrypto loaded in 0002)
  • timestamptz NOT NULL DEFAULT now() for all timestamp columns
  • ON DELETE CASCADE for FK to users(id)
  • Named index pattern: tablename_columnname_idx
  • -- +goose Down drops tables in reverse dependency order
  • Comment on retained extensions at Down block end

backend/internal/db/queries/tablos.sql (query, CRUD)

Analog: backend/internal/db/queries/users.sql

Full analog file (lines 1-10):

-- name: InsertUser :one
INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING id, email, password_hash, created_at, updated_at;

-- name: GetUserByEmail :one
SELECT id, email, password_hash, created_at, updated_at
FROM users
WHERE email = $1;

Copy conventions:

  • -- name: FuncName :one/:many/:exec annotation on every query (sqlc requires it)
  • Explicit column list in SELECT (no SELECT *)
  • RETURNING on INSERT/UPDATE to get the full row back without a second query
  • $1, $2, ... positional params (pgx/v5 style)
  • :exec for DELETE (no return value needed)
  • :one for INSERT/UPDATE RETURNING + single-row SELECT
  • :many for list queries

Note on nullable columns: description text and color text are nullable. sqlc with pgx/v5 generates pgtype.Text for these. Templates must check .Valid before using .String.


backend/internal/web/handlers_tablos.go (handler, request-response)

Analog: backend/internal/web/handlers_auth.go

Imports pattern (lines 1-18):

package web

import (
    "errors"
    "log/slog"
    "net"
    "net/http"
    "net/mail"
    "strings"

    "backend/internal/auth"
    "backend/internal/db/sqlc"
    "backend/templates"

    "github.com/gorilla/csrf"
    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgconn"
)

Deps struct pattern (lines 25-30):

// AuthDeps holds the dependencies shared by all auth handlers.
type AuthDeps struct {
    Queries *sqlc.Queries
    Store   *auth.Store
    Secure  bool
    Limiter *auth.LimiterStore
}

For tablos, copy this as:

// TablosDeps holds dependencies for all tablo handlers.
type TablosDeps struct {
    Queries *sqlc.Queries
}

Handler constructor pattern (lines 50-55):

// SignupPageHandler renders the GET /signup page with an empty form.
func SignupPageHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        _ = templates.SignupPage(templates.SignupForm{}, templates.SignupErrors{}, csrf.Token(r)).Render(r.Context(), w)
    }
}

Every handler returns http.HandlerFunc from a constructor that closes over the deps struct.

HTMX-aware redirect pattern (lines 137-143):

// HTMX form submissions receive HX-Redirect so HTMX handles navigation client-side.
// Plain (no-JS) form submissions receive 303 See Other (NOT 302 — Pitfall 9).
if r.Header.Get("HX-Request") == "true" {
    w.Header().Set("HX-Redirect", "/")
    w.WriteHeader(http.StatusOK)
    return
}
http.Redirect(w, r, "/", http.StatusSeeOther)

Fragment vs full-page dispatch pattern (lines 149-157):

func renderSignupError(w http.ResponseWriter, r *http.Request, form templates.SignupForm, errs templates.SignupErrors, status int) {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    w.WriteHeader(status)
    if r.Header.Get("HX-Request") == "true" {
        _ = templates.SignupFormFragment(form, errs, csrf.Token(r)).Render(r.Context(), w)
    } else {
        _ = templates.SignupPage(form, errs, csrf.Token(r)).Render(r.Context(), w)
    }
}

Form value reading (lines 71-72 and 188-189):

// Always r.PostFormValue, never r.Body — gorilla/csrf consumes the body.
email := strings.TrimSpace(r.PostFormValue("email"))
password := r.PostFormValue("password")

pgx.ErrNoRows handling (lines 227-231):

user, err := deps.Queries.GetUserByEmail(ctx, normalized)
if err != nil {
    if errors.Is(err, pgx.ErrNoRows) {
        // handle not found
        return
    }
    http.Error(w, "internal server error", http.StatusInternalServerError)
    return
}

auth.Authed usage (lines 248-251):

// Extract authenticated session — RequireAuth middleware guarantees this is set.
sess, _, ok := auth.Authed(r.Context())
if !ok {
    http.Redirect(w, r, "/login", http.StatusSeeOther)
    return
}

For tablo handlers the three-value form is:

_, user, _ := auth.Authed(r.Context())
// user is guaranteed non-nil inside RequireAuth-gated routes

Ownership check pattern (new for Phase 3 — derived from D-04):

tablo, err := deps.Queries.GetTabloByID(r.Context(), tabloID)
if err != nil {
    if errors.Is(err, pgx.ErrNoRows) {
        http.NotFound(w, r)
        return
    }
    http.Error(w, "internal server error", http.StatusInternalServerError)
    return
}
// D-04: 404 for non-owner, not 403 — no information leakage
if tablo.UserID != user.ID {
    http.NotFound(w, r)
    return
}

slog error logging pattern (lines 306-310):

if err := deps.Store.Delete(r.Context(), sess.ID); err != nil {
    slog.Default().Error("logout: delete session", "session_id", sess.ID, "err", err)
    // Continue — partial invalidation is better than leaving state intact.
}

backend/templates/tablos.templ (component, request-response)

Analog: backend/templates/auth_signup.templ (form + fragment pattern) and backend/templates/index.templ (dashboard page with Card + Button + HTMX attrs)

Package declaration and imports (auth_signup.templ lines 1-3, index.templ lines 1-8):

package templates

import (
    "backend/internal/auth"
    "backend/internal/db/sqlc"
    "backend/internal/web/ui"
)

Full-page template wrapping Layout (auth_signup.templ lines 8-19):

templ SignupPage(form SignupForm, errs SignupErrors, csrfToken string) {
    @Layout("Sign up", nil, csrfToken) {
        <div class="flex min-h-[60vh] items-start justify-center pt-16">
            @ui.Card(nil) {
                <div class="w-full max-w-sm px-6 py-8">
                    <h1 class="mb-6 text-2xl font-semibold">Create your account</h1>
                    @SignupFormFragment(form, errs, csrfToken)
                </div>
            }
        </div>
    }
}

Fragment component with HTMX form (auth_signup.templ lines 25-72):

templ SignupFormFragment(form SignupForm, errs SignupErrors, csrfToken string) {
    <form
        id="signup-form"
        method="POST"
        action="/signup"
        hx-post="/signup"
        hx-target="#signup-form"
        hx-swap="outerHTML"
        class="space-y-5"
    >
        @ui.CSRFField(csrfToken)
        @GeneralError(errs.General)
        ...
        @ui.Button(ui.ButtonProps{
            Label:   "Create account",
            Variant: ui.ButtonVariantDefault,
            Tone:    ui.ButtonToneSolid,
            Size:    ui.SizeMD,
            Type:    "submit",
        })
    </form>
}

Button with HTMX attributes (index.templ lines 27-39):

@ui.Button(ui.ButtonProps{
    Label:   "Fetch server time",
    Variant: ui.ButtonVariantDefault,
    Tone:    ui.ButtonToneSolid,
    Size:    ui.SizeMD,
    Type:    "button",
    Attrs: templ.Attributes{
        "hx-get":       "/demo/time",
        "hx-target":    "#demo-out",
        "hx-swap":      "innerHTML",
        "hx-indicator": "#demo-spinner",
    },
})

ui.Card with arbitrary attrs (index.templ lines 21-22):

@ui.Card(nil) {
    // children here
}
// With attrs:
@ui.Card(templ.Attributes{"id": "tablo-123"}) {
    // children here
}

pgtype.Text null check in templates (from RESEARCH Pitfall 6):

if tablo.Description.Valid && tablo.Description.String != "" {
    <p class="mt-2 text-base text-slate-600">{ tablo.Description.String }</p>
}

FieldError and GeneralError (auth_form_errors.templ lines 5-19):

templ FieldError(msg string) {
    if msg != "" {
        <p class="mt-1 text-sm text-red-700">{ msg }</p>
    }
}

templ GeneralError(msg string) {
    if msg != "" {
        <div class="mb-4 rounded border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
            { msg }
        </div>
    }
}

OOB swap template shape (from RESEARCH Pattern 4 — new pattern, no existing analog):

// TabloCardWithOOBFormClear renders a card plus an OOB element to clear #create-form-slot.
// The OOB div MUST be a top-level sibling of the card, not nested (RESEARCH Pitfall 5).
templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) {
    @TabloCard(tablo, csrfToken)
    <div id="create-form-slot" hx-swap-oob="true"></div>
}

backend/internal/web/router.go (config, request-response — MODIFIED)

Analog: Self. Current file backend/internal/web/router.go.

Current protected group pattern (lines 74-78):

// Protected routes — require an authenticated session (D-23, AUTH-05).
r.Group(func(r chi.Router) {
    r.Use(auth.RequireAuth)
    r.Get("/", IndexHandler())
    r.Post("/logout", LogoutHandler(deps))
})

NewRouter signature pattern (line 47):

func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler {

What to add — NewRouter signature change:

func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler {

What to add — protected group extension:

r.Group(func(r chi.Router) {
    r.Use(auth.RequireAuth)
    r.Get("/", TablosListHandler(tabloDeps))        // replaces IndexHandler
    r.Post("/logout", LogoutHandler(deps))
    // Static segment BEFORE parametric — chi static takes precedence when declared first
    r.Get("/tablos/new", TablosNewHandler(tabloDeps))
    r.Post("/tablos", TablosCreateHandler(tabloDeps))
    r.Get("/tablos/{id}", TabloDetailHandler(tabloDeps))
    r.Post("/tablos/{id}", TabloUpdateHandler(tabloDeps))
    r.Get("/tablos/{id}/edit-title", TabloEditTitleHandler(tabloDeps))
    r.Get("/tablos/{id}/show-title", TabloShowTitleHandler(tabloDeps))
    r.Get("/tablos/{id}/edit-desc", TabloEditDescHandler(tabloDeps))
    r.Get("/tablos/{id}/show-desc", TabloShowDescHandler(tabloDeps))
    r.Get("/tablos/{id}/delete-confirm", TabloDeleteConfirmHandler(tabloDeps))
    r.Get("/tablos/{id}/delete-cancel", TabloDeleteCancelHandler(tabloDeps))
    r.Post("/tablos/{id}/delete", TabloDeleteHandler(tabloDeps))
})

Route ordering rule: GET /tablos/new MUST be declared before GET /tablos/{id}. chi v5 resolves static segments before parametric at the same depth, but explicit declaration order is safest.


backend/cmd/web/main.go (config — MODIFIED)

Analog: Self. Current file backend/cmd/web/main.go.

Current deps construction and router call (lines 78-80):

deps := web.AuthDeps{Queries: q, Store: store, Secure: secure, Limiter: rl}

router := web.NewRouter(pool, "./static", deps, csrfKey, env)

What to add:

deps := web.AuthDeps{Queries: q, Store: store, Secure: secure, Limiter: rl}
tabloDeps := web.TablosDeps{Queries: q}

router := web.NewRouter(pool, "./static", deps, tabloDeps, csrfKey, env)

Note: tabloDeps shares the same *sqlc.Queries instance (q) as deps. No new DB pool needed.


Analog: Self. Current file backend/templates/layout.templ.

Current footer (line 51):

<footer class="mx-auto max-w-5xl px-4 sm:px-6 py-6 text-sm text-slate-600">
    Phase 2 · Authentication
</footer>

Change to:

<footer class="mx-auto max-w-5xl px-4 sm:px-6 py-6 text-sm text-slate-600">
    Phase 3 · Tablos
</footer>

No other layout changes. All structural classes, container width, asset paths, and logout form are locked by UI-SPEC.


backend/templates/index.templ (component — DELETED or emptied)

Analog: Self. Current content replaced by tablo dashboard in tablos.templ.

IndexHandler() in handlers.go (the Phase 1 placeholder) should also be removed or replaced. The GET / route in router.go is reassigned to TablosListHandler(tabloDeps).

The HTMX demo (/demo/time + DemoTimeHandler) stays — it is not removed in Phase 3.


backend/internal/web/ui/button.css (config — MODIFIED: add danger + neutral variants)

Analog: Self. Current file backend/internal/web/ui/button.css.

Existing variant pattern (lines 29-48):

.ui-button-solid-default-md {
  display: inline-flex;
  align-items: center;
  border-radius: 0.375rem;
  background-color: #2563eb;
  padding: 0.5rem 1rem;
  font-size: 1rem;
  font-weight: 600;
  color: #ffffff;
}

.ui-button-solid-default-md:hover {
  background-color: #1d4ed8;
}

.ui-button-solid-default-md:focus-visible {
  outline: 2px solid #1d4ed8;
  outline-offset: 2px;
}

CSS conventions to follow (line 3-4 comment):

  • No CSS nesting (&:hover) — all pseudo-class rules are top-level selectors
  • Class name format: .ui-button-{tone}-{variant}-{size} (matches ButtonClass() in variants.go)

New classes needed per UI-SPEC:

ui-button-solid-danger-md — for delete confirmation button (red, solid):

.ui-button-solid-danger-md {
  display: inline-flex;
  align-items: center;
  border-radius: 0.375rem;
  background-color: #dc2626;
  padding: 0.5rem 1rem;
  font-size: 1rem;
  font-weight: 600;
  color: #ffffff;
}

.ui-button-solid-danger-md:hover {
  background-color: #b91c1c;
}

.ui-button-solid-danger-md:focus-visible {
  outline: 2px solid #dc2626;
  outline-offset: 2px;
}

ui-button-soft-neutral-md — for cancel buttons (ghost-style, muted):

.ui-button-soft-neutral-md {
  display: inline-flex;
  align-items: center;
  border-radius: 0.375rem;
  background-color: transparent;
  border: 1px solid #cbd5e1;
  padding: 0.5rem 1rem;
  font-size: 1rem;
  font-weight: 500;
  color: #475569;
}

.ui-button-soft-neutral-md:hover {
  background-color: #f1f5f9;
}

.ui-button-soft-neutral-md:focus-visible {
  outline: 2px solid #64748b;
  outline-offset: 2px;
}

Usage in templ:

@ui.Button(ui.ButtonProps{
    Label:   "Delete",
    Variant: ui.ButtonVariantDanger,
    Tone:    ui.ButtonToneSolid,
    Size:    ui.SizeMD,
    Type:    "submit",
})

@ui.Button(ui.ButtonProps{
    Label:   "Cancel",
    Variant: ui.ButtonVariantNeutral,
    Tone:    ui.ButtonToneSoft,
    Size:    ui.SizeMD,
    Type:    "button",
    Attrs: templ.Attributes{
        "hx-get":    "/tablos/{id}/delete-cancel",
        "hx-target": "#tablo-delete-zone",
        "hx-swap":   "outerHTML",
    },
})

Note: ButtonVariantDanger and ButtonVariantNeutral already exist in variants.go (lines 20 and 18). ButtonToneSoft exists at line 32. The CSS classes are the only missing piece.


Shared Patterns

Authentication — auth.Authed extraction

Source: backend/internal/auth/middleware.go lines 26-32 Apply to: All handler functions in handlers_tablos.go

// Authed extracts the session and user from the request context.
// Returns (session, user, true) when a valid session is present.
func Authed(ctx context.Context) (*Session, *User, bool) {
    a, ok := ctx.Value(sessionKey).(*authed)
    if !ok || a == nil {
        return nil, nil, false
    }
    return a.Session, a.User, true
}

Inside RequireAuth-gated routes, the user is always present. Use the short form:

_, user, _ := auth.Authed(r.Context())

HTMX-aware redirect helper

Source: backend/internal/auth/middleware.go lines 119-126 Apply to: TabloDeleteHandler (post-delete navigation to /) and any handler that redirects

func redirectTo(w http.ResponseWriter, r *http.Request, target string) {
    if r.Header.Get("HX-Request") == "true" {
        w.Header().Set("HX-Redirect", target)
        w.WriteHeader(http.StatusOK)
        return
    }
    http.Redirect(w, r, target, http.StatusSeeOther)
}

This is an unexported function in the auth package. Replicate it inline in handlers or extract a shared helper in the web package. Do not add a new dependency on auth internals.

CSRF token injection

Source: backend/internal/web/ui/csrf_field.templ lines 7-9 Apply to: Every <form method="POST"> in tablos.templ

templ CSRFField(token string) {
    <input type="hidden" name="_csrf" value={ token }/>
}

Call as @ui.CSRFField(csrfToken) as the first child of every POST form.

gorilla/csrf token extraction

Source: backend/internal/web/handlers_auth.go (throughout — e.g. line 53) Apply to: Every handler that renders a template with a form

csrf.Token(r)  // import "github.com/gorilla/csrf"

Pass as csrfToken parameter to all template constructors.

Content-Type header

Source: backend/internal/web/handlers_auth.go lines 52, 162, 150 Apply to: Every handler that writes HTML

w.Header().Set("Content-Type", "text/html; charset=utf-8")

Set before calling .Render(). For error responses, set before w.WriteHeader(status).

UUID param extraction

Source: RESEARCH.md Pattern 6 (verified against go.mod) Apply to: All tablo handlers that accept {id} URL param

import (
    "github.com/go-chi/chi/v5"
    "github.com/google/uuid"
)

idStr := chi.URLParam(r, "id")
tabloID, err := uuid.Parse(idStr)
if err != nil {
    http.NotFound(w, r)
    return
}

Dual-target HTMX create response (HX-Retarget + hx-swap-oob)

Source: RESEARCH.md Pattern 4 (new pattern — no prior codebase analog) Apply to: TablosCreateHandler success path only

// Successful create: retarget to #tablos-list, prepend card, OOB-clear form slot.
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("HX-Retarget", "#tablos-list")
w.Header().Set("HX-Reswap", "afterbegin")
_ = templates.TabloCardWithOOBFormClear(tablo, csrf.Token(r)).Render(r.Context(), w)

The OOB element MUST be a top-level sibling in the response body, not nested (RESEARCH Pitfall 5).

Input validation via r.PostFormValue

Source: backend/internal/web/handlers_auth.go lines 71-72 Apply to: All POST handlers in handlers_tablos.go

// Pitfall 2: gorilla/csrf consumes r.Body. Always use r.PostFormValue, never
// r.Body / io.ReadAll. r.PostFormValue calls r.ParseForm which caches the result.
title := strings.TrimSpace(r.PostFormValue("title"))
description := r.PostFormValue("description")
color := strings.TrimSpace(r.PostFormValue("color"))

No Analog Found

No files are entirely without analog. All new files have at least a role-match or structural analog in the codebase. The only genuinely new patterns (OOB swap, HX-Retarget/HX-Reswap, ownership 404 check, UUID param extraction) are covered in the Shared Patterns section above and documented in RESEARCH.md with verification sources.


Metadata

Analog search scope: backend/internal/web/, backend/templates/, backend/migrations/, backend/internal/db/queries/, backend/internal/auth/, backend/cmd/web/, backend/internal/web/ui/ Files read: 18 Pattern extraction date: 2026-05-14