Skip to content

Rewrite in Go with templ, HTMX and sqlc on Postgres#39

Open
malpou wants to merge 4 commits into
mainfrom
rewrite-go-htmx-templ-sqlc
Open

Rewrite in Go with templ, HTMX and sqlc on Postgres#39
malpou wants to merge 4 commits into
mainfrom
rewrite-go-htmx-templ-sqlc

Conversation

@malpou

@malpou malpou commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Replaces the SvelteKit/Cloudflare/D1 stack with Go. Behaviour is unchanged, and
that isn't a claim: all seven Playwright spec files pass byte-for-byte
untouched
, which is what pinned nearly every decision in here.

git diff main -- 'e2e/*.spec.ts'   # empty

The stack

  • Go + templ render the HTML.
  • HTMX owns everything that talks to the server. Forms keep naming their
    action in the query string (?/addOption), and hx-post intercepts the
    submit so a rejected form re-renders in place without changing the URL.
  • Alpine owns the in-page state that never needed a round-trip: the
    preference selector, the submit gate, the inline edit and expand toggles.
  • sqlc compiles internal/db/queries.sql into typed Go over pgx/v5,
    generated into internal/db beside store.go, so the store uses the
    generated row types directly and there is no mapping layer.
  • Tailwind 4 builds to a static static/app.css.

Worth a look in review

Dates. The Go stdlib ships no CLDR data, so internal/domain/date.go spells
out the weekday/month names and the per-locale date and time shapes. Two traps
worth knowing about, both pinned in date_test.go: Danish writes the time with
a dot (kl. 10.00) where en/fr use a colon (at 10:00), and the same UTC
instant renders 10.00 in September but 09.00 in January.

Hidden vs absent. Collapsed notes and respondent names use x-if, not
x-show. Two dashboard specs assert toHaveCount(0) on collapsed content, which
only holds if it is absent from the DOM rather than merely hidden - and a
collapsed card genuinely shouldn't ship who answered to the client.

One source of truth for copy. messages/{da,en,fr}.json is read by the Go
server to render and by the specs to assert, so copy cannot drift between the app
and the tests. That is the only reason src/lib/paraglide still exists: the
specs import it by that path.

Generated code is committed and marked in .gitattributes, so a fresh clone
builds and tests without templ/sqlc/tailwind installed. CI runs make build and
fails if the tree is dirty afterwards, so it can't rot.

The harness (the only thing touched outside the app)

The specs are untouched; their harness is not. e2e/db.ts keeps its exact
exported API but seeds through psql instead of wrangler. global-setup.ts is
gone: Playwright waits for webServer to be ready before it would run any
global setup, so a database started there is too late for the server that needs
it. The database now comes up with the server via compose.yaml.

Deploy note

This is no longer a Worker. It's a single Go binary plus static/, needing
DATABASE_URL and optionally PORT, so poll.malpou.io needs somewhere that
can run it. Apply internal/db/migrations/0001_init.sql to a fresh database
before first boot.

malpou added 4 commits July 16, 2026 21:58
Replaces the SvelteKit/Cloudflare/D1 stack. Behaviour is unchanged: all seven
Playwright spec files pass byte-for-byte untouched, which is what pinned every
decision here.

- Go + templ renders the HTML. HTMX handles the server round-trips (the form
  actions keep their ?/{action} URLs); Alpine holds the in-page state that never
  needed a round-trip - the preference selector, the submit gate, the inline
  edit and expand toggles.
- sqlc compiles internal/db/queries.sql to typed Go over pgx/v5. All app SQL
  lives in that one file, as it did in the old data provider.
- messages/{da,en,fr}.json is now read by both sides: the Go server renders from
  it, the specs assert against it via Paraglide, so copy cannot drift.
- Go has no CLDR data, so internal/domain/date.go transcribes the exact
  Intl.DateTimeFormat output the app produced - including Danish's dot time
  separator (kl. 10.00) against en/fr's colon (at 10:00). date_test.go pins
  those strings and the DST boundaries.

Only the e2e harness changed, never the specs: db.ts seeds through psql instead
of wrangler, and the database now comes up with the server (scripts/pg.sh)
because Playwright waits for webServer readiness before running globalSetup.

Generated code (templ, sqlc, Paraglide, Tailwind) is committed and marked in
.gitattributes, so a fresh clone builds and tests without the generator
toolchain; CI regenerates and fails if the tree is dirty.
The generated query code lands in internal/db alongside store.go instead of a
nested sqlc package, so the store uses sqlc's row types directly and the second
set of hand-written structs and their mapping layer are gone. New/NewStore split
keeps the two constructors apart.

Also drops the running commentary about how the code came to be: the comments
now explain what the code does and why, without narrating what it replaced.
pg.sh existed to start Postgres and apply the schema before the server; that is
two lines of compose plus two of psql, so it now lives in compose.yaml and the
db target where it's visible. CI keeps its own service and sets DB_READY=1 to
skip it.

gen-types.sh generated two type aliases that are fixed by the data model, out of
a sed pipeline over a CHECK constraint. A generator for a constant is machinery
with no payoff, so src/lib/types.ts is a plain file again.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant