TechHousekeeping
Living status page for the tech housekeeping effort tracked in creatorsgarten/contentsgarten#464. Five threads:
- Update dependencies
- Adding Elysia-based REST API
- Migrating to Vite+
- Reducing weight to just the actively-used parts
- Data synchronizing via GitHub webhooks
Update dependencies
Status (2026-07-24): unstuck — all clear. No Renovate PR had merged in ~8 months (newest merged commit was 2025-11-16), and by the time this was picked up there were 10+ open Renovate PRs (oldest from 2024-12-16), all failing CI. It wasn't one root cause blocking everything — investigation found several separate, unrelated build breaks stacked on top of each other, fixed one at a time:
- Docker build failed for every PR, regardless of what it bumped.
Dockerfile.creatorsgartenranyarn global add pnpm turbo@^1.8.3unpinned onnode:hydrogen(Node 18). pnpm now ships v11, which requires Node >=22.13, so the install failed outright. Fixed by #465: base image bumped tonode:24,corepack enableused instead of a floatingyarn global add pnpm(dtinth wanted Node v24 as the new baseline anyway). wiki.wonderful.softwarefailed to compile oncefirebasemoved past10.0.0— webpack resolvedfirebase/authto its Node build (pulling inundici, whose ES2022 syntax this webpack version's parser choked on) even in client bundles. Fixed by #466: stubbedundiciout via a webpack alias, since it isn't needed (nativefetchis used).packages/contentsgartenfailed to typecheck on the octokit v3.2.2 bump — a hand-rolled commit-author type didn't account for octokit'sRecord<string, never>"unknown author" case. Fixed by #467: derived the type from octokit's ownlistCommitsreturn type instead.@tsconfig/node18→@tsconfig/node20surfaced a deep unexported import inmicromark-extension-directive, plus a CJS/ESM default-export typing hazard inhtml-react-parserunder Node's strictmoduleResolution. Fixed by #468: fixed the import, and switched the tsup-bundled packages tomoduleResolution: "bundler"(they're built by esbuild, not run directly by Node, so Node's runtime resolution rules were the wrong fit).micromark-extension-directivev4 movedDirective/Handleto the package root and narrowedHandle's return type toboolean | undefined. Fixed in the PR that bumped it.hast-util-to-stringv3,hast-util-heading-rankv3, andrehypev13 had to move together — they share@types/hast, and bumping only one at a time left two incompatible major versions of that type package in the tree. Bumped all three together in one PR, closed the other two as superseded, and added apnpm.overridespin for@types/hast(two different patch versions were still coexisting and getting treated as distinct types).- Turbo v1 → v2 required renaming
pipelinetotasksinturbo.json(hard error in v2), fixing an invalid"version": nullincontentsgarten.netlify.app/package.json, and updatingturbo prune's CLI syntax. Also fixed a pre-existing gap while in there:wiki.wonderful.software's.next/**output wasn't inturbo.json'soutputslist, so it was never actually being cached. - Next.js 13.4.5 → 13.5.11 needed
Source_Sans_Prorenamed toSource_Sans_3— Google renamed the font family upstream, and the newer Next version's bundled font list only recognizes the new name.
End state: every open Renovate PR either merged or was closed as superseded by a merged one. Zero open PRs left as of 2026-07-24.
CI reliability follow-up. Two of the Playwright Tests runs that day
hung for 9-34 minutes with no error, both stuck in the same step:
pnpm exec playwright install --with-deps's apt-get install, pulling
browser OS dependencies from azure.archive.ubuntu.com at ~35-60 KB/s
instead of normal runner speed (e.g. a single 13.6 MB package took 6m31s).
Cancel + rerun fixed both immediately, pointing at transient upstream
mirror degradation rather than anything in this repo. dtinth had already
solved this class of problem: swapped that step for
dtinth/setup-playwright-test-docker,
which connects to a prebuilt Playwright Docker image instead of installing
browsers from scratch — the "Install Playwright Browsers" step in
.github/workflows/playwright.yml no longer touches apt at all. First run
after switching: test check in 1m44s (previously 2-4 min even on a good
run).
Adding Elysia-based REST API
Status (2026-07-26): shipped end to end — both consumer apps migrated, tRPC removal is the last step. Picked up right after the dead-export cut, since knowing what's actually load-bearing fed directly into deciding the REST API's endpoint scope. Tracked as a sub-issue: creatorsgarten/contentsgarten#495 (design, via a full requirements-interview session before any code was written — every decision below was confirmed with dtinth there first).
Design, in short: a REST + OpenAPI surface to replace the
openapi-trpc-generated-from-tRPC hack currently used for docs, driven by
what the 3 known first-party consumers (creatorsgarten.org,
wiki.creatorsgarten.org, contentsgarten.netlify.app) need — opening
the API to non-TS/third-party consumers is a side effect, not the design
driver. Both APIs coexist during the transition; tRPC isn't being removed
yet.
#498 added
the package capability: full parity with all 7 tRPC procedures, built
directly inside packages/contentsgarten (Elysia added as a direct
dependency, so server-side consumers don't need to import a second
package). Reuses the existing Zod schemas as-is (Elysia supports Standard
Schema natively) and all existing business logic — duplication is scoped
to the routing/HTTP layer only, down to extracting the auth-resolution
helpers (resolveAuthState/authorize/createGitHubHelpers) into a
shared module so both routers call the literal same code, not copies.
OpenAPI spec is generated at build time (app.handle() called in-memory
against a fake instance, no server needs to run) and published as a
dist/openapi.json npm subpath export. Also the package's first-ever test
suite (vitest, hitting the Elysia app directly via app.handle()).
Two things approved in the design session turned out wrong once actually building it, both caught and fixed the same day:
- Elysia — like path routers generally — can't match a static segment
after a wildcard.
/pages/*/contributorssilently never matches (the bare/pages/*route swallows the request instead). Verified empirically with a throwaway smoke script before relying on it. ChangedgetContributors/getEditPermission's URLs from/pages/*/contributorsand/pages/*/permissionto/page-contributors/*and/page-permission/*. - Validation failures return
422, not the400floated in the design discussion — that's Elysia's default for a failed schema, and it's arguably the more correct status for "well-formed but semantically invalid" input anyway, so left as-is rather than fighting the framework.
Follow-up, prompted by dtinth evaluating openapi-fetch
for creatorsgarten.org: checked directly (ran openapi-typescript
against the generated spec) whether the wildcard-in-path-key issue above
would trip up typed codegen. It doesn't — openapi-typescript treats
/pages/* as a literal key fine and correctly types the path parameter
from the per-operation schema. But that check surfaced a real, unrelated
gap: 6 of 7 endpoints had no declared response schema in the Elysia
route config, so their generated types came out as responses: never —
which would have broken typed codegen. Fixed same PR: added response
schemas to all 7 (reusing the same shapes the tRPC router's .output()
already declares where one exists). Also learned the hard way that Elysia
validates the response against the schema using the actual runtime
value (e.g. a real Date object), not its eventual JSON-serialized form
— so date fields need z.date(), not z.string().
#503 mounts
it in wiki.creatorsgarten.org at /api/wiki, alongside the existing
/api/contentsgarten tRPC mount — package-only scope was deliberate in
#495 so this mounting step could be reviewed/landed separately.
handleContentsgartenRestRequest gained an optional prefix argument
(mirroring the tRPC handler's existing shape) so it can be mounted
anywhere; verified Elysia's prefix config composes correctly with
wildcard routes before relying on it. The instance-construction and
dev-mode production-proxy logic, previously private to the tRPC route
file, got extracted into a shared module so both routes use identical
credentials wiring instead of two independently-maintained copies.
creatorsgarten.org and contentsgarten.netlify.app are not mounted
yet — that's a separate follow-up per app, at their own pace.
A second wrong-in-practice design decision, more serious this time
(#506):
before starting the creatorsgarten.org/contentsgarten.netlify.app
migrations to REST (asked for next, once #503 was up), tested
openapi-fetch end-to-end for real — not just openapi-typescript type
generation, which is what the earlier check actually verified and which
turned out to not be sufficient signal. openapi-fetch always
percent-encodes path-parameter values (encodeURIComponent, no
allowReserved opt-out for path params — confirmed by reading its
source), and Elysia doesn't decode %2F before wildcard-matching a path
(confirmed empirically). So a multi-segment pageRef like Foo/Bar could
never correctly round-trip through openapi-fetch's typed path-param
calling convention on any of the 4 endpoints that take one — most of what
a real consumer actually calls. Not fixable by reshaping the OpenAPI path
key (tried both the literal /pages/* and a proper /pages/{path}
placeholder) — the problem is / inside a path value, not the wildcard
syntax.
Fixed by moving pageRef to a query parameter on all 4 affected
endpoints instead of a path segment:
| Before | After |
|---|---|
GET /pages/* | GET /page?pageRef=... |
GET /page-contributors/* | GET /page-contributors?pageRef=... |
GET /page-permission/* | GET /page-permission?pageRef=... |
PUT /pages/* | PUT /page?pageRef=... |
This also removes the wildcard-routing complexity that caused the
/pages/*/contributors → /page-contributors/* rename above — no more
wildcards in the REST API's paths at all. Verified this time with a real
openapi-fetch client (generated types from the new spec) making an
actual typed call with a multi-slash pageRef, confirming it round-trips
exactly through Elysia's query parsing.
Since this reshapes the REST API before any consumer integrated against
it, per dtinth: entered changesets prerelease mode
(changeset pre enter next, tag next) and added a major changeset for
contentsgarten.
Lesson for next time: "does the codegen tool produce valid types" and "does the codegen tool's client actually call the API correctly" are two different questions — checking only the first gave false confidence.
#508 fixed a
second query-param regression, caught by a real end-to-end call rather than
type-checking alone: the /pages search endpoint declared q: z.string(),
but Elysia auto-parses JSON-looking query values into objects before
running the declared schema — so any non-empty search query failed
validation. Only the empty-query case (no q at all) happened to pass,
which is why the original test suite missed it. Fixed by declaring
q: PageDatabaseSearch.optional() directly instead of a plain string, with
a regression test using a real non-empty query.
creatorsgarten.org and contentsgarten.netlify.app (the latter via a
background teammate, in parallel) were then migrated off tRPC onto the REST
API via openapi-fetch, mounted at /api/wiki. creatorsgarten.org's
existing e2e test suite was also failing on main independent of any of
this (pre-existing site-redesign drift — stale assertions expecting removed
homepage/events copy); fixed alongside the migration since dtinth asked for
it while in there.
main CI broke again on 2026-07-26, unrelated to the API work itself:
the Release workflow's pnpm changeset publish step started 404ing on
every push once #507 put contentsgarten into prerelease mode and
attempted its first-ever publish of contentsgarten@3.0.0-next.0. Root
cause turned out to be the classic NPM_TOKEN's permission scope, not
anything in this repo's code. Fixed two ways in quick succession: dtinth
patched the token directly (one publish went through on the old
credential), and in parallel
#527 migrated
the workflow to npm's trusted publishing
(OIDC) so this class of failure can't recur — no more long-lived npm
secret to expire or under-scope. That required bumping pnpm from 9.15.9
to 11.17.0 (OIDC trusted publishing needs pnpm ≥10.19; verified via
pnpm/pnpm issue history, not assumed) and pnpm/action-setup from v2 to
v6 (an early pnpm 11 release briefly broke the changeset publish →
npm publish OIDC handoff specifically, tracked in
pnpm/pnpm#11566; newer
action-setup + newer pnpm together resolved it). The pnpm major bump
itself required two follow-up fixes, both caught by actually running
pnpm install/build/test locally rather than assuming a version-number
bump is risk-free: pnpm.overrides in root package.json is silently
ignored by pnpm 10+ (moved to pnpm-workspace.yaml's overrides: key),
and pnpm 10+ blocks native postinstall scripts by default
(ERR_PNPM_IGNORED_BUILDS) — allowlisted @sentry/cli, deasync,
esbuild, protobufjs, and sharp in pnpm-workspace.yaml's
allowBuilds:, all genuine native-binary installers the build already
depended on. The OIDC path itself wasn't actually exercised by the PR's own
merge (nothing new needed publishing at that point) — still watching for
the next real prerelease publish to confirm it end-to-end.
Once #527 was in, dtinth asked to clear the accumulated Renovate PR
backlog on main — 8 more merged the same session (zod-to-json-schema,
actions/checkout@v7, actions/setup-node@v7,
actions/upload-artifact@v7, @types/node v24, concurrently v10,
eslint v10, vitest v4). A literal git octopus merge
(git merge -s octopus) was tried first per dtinth's request but aborted
immediately — every one of these PRs touches pnpm-lock.yaml, and octopus
has no conflict-resolution step, so it fails outright on the first clash
rather than partially succeeding. Merged sequentially instead, resolving
the lockfile conflict and rerunning build/test after each one landed.
Renovate's own automation kept racing to re-rebase the same branches mid-fix
(a recurring friction noted earlier in this doc too) — a couple of these
needed a second resolve-and-push cycle after Renovate's rebase landed first.
Left open on purpose: a typescript v7 bump with a real, unfixed CI
failure, and a fresh astro major-version PR not yet looked at.
Migrating to Vite+
Status (2026-07-24): researched, not started. Vite+ is being
considered only as a Turborepo replacement (task running), not as an
Astro replacement — wiki.creatorsgarten.org stays on Astro either way.
Full research write-up: ViteplusVsTurborepo.
Top-line recommendation from that research: wait. Vite+'s task
runner/caching model is architecturally close to what this repo already
gets from Turborepo, but it's three weeks into a beta that self-describes
as "not yet complete," its licensing terms already changed once in nine
months, and its docs don't yet cover two things this repo depends on
(persistent dev tasks, explicit build outputs tracking). Revisit once
Vite+ reaches a stable release.
Reducing weight to just the actively-used parts
Status (2026-07-24): dead exports cut. Picked as the next thread after dependencies got unblocked — it's cheap to start and informs the other two (Elysia API, webhook sync) by clarifying what's actually load-bearing first. Full audit: ApiSurfaceAudit.
#490
un-exported (deleted where fully unused, otherwise kept for internal use)
defineConfig, PageRef/LaxPageRef/PageRefRegex,
CreateContextInput/createContextFromRequest, ContentsgartenUserConfig's
5 backend-specific sub-interfaces, testing's internal helpers beyond
createFakeInstance, and DirectiveType/LinkProps from
@contentsgarten/html. Verified by checking the generated .d.ts files
directly — only the actually-used exports remain.
Left open: @contentsgarten/markdown's publish status (whether to stop
publishing it standalone — no consumer, internal or external, imports it
directly). That's a policy call, not a mechanical cut.
Consumer tiers, per dtinth: there is no "internal only" tier. All three
sites that consume contentsgarten — creatorsgarten.org,
wiki.creatorsgarten.org, and contentsgarten.netlify.app — are real
production consumers, including contentsgarten.netlify.app itself (it
powers the live contentsgarten-wiki content repo and dogfoods the
public API in production, not just a throwaway demo).
wiki.wonderful.software was defunct and has been removed from the
repo (#474).
Two corrections were needed to get here: the first audit pass wrongly
called wiki.creatorsgarten.org "internal"; the fix for that still wrongly
called contentsgarten.netlify.app "internal/dogfooding-only." Both
undersold real usage — both sites self-host a full engine instance
(GitHub App + MongoDB + Firebase, plus Redis for netlify.app). The
genuinely dead exports still stand though: defineConfig,
PageRef/LaxPageRef/PageRefRegex, ContentsgartenUserConfig
variants, most of testing.*, and a couple of unused types in
@contentsgarten/html — none of those were ever used by any of the three
sites.
Data synchronizing via GitHub webhooks
Not started as of 2026-07-25.