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:

  1. Docker build failed for every PR, regardless of what it bumped. Dockerfile.creatorsgarten ran yarn global add pnpm turbo@^1.8.3 unpinned on node:hydrogen (Node 18). pnpm now ships v11, which requires Node >=22.13, so the install failed outright. Fixed by #465: base image bumped to node:24, corepack enable used instead of a floating yarn global add pnpm (dtinth wanted Node v24 as the new baseline anyway).
  2. wiki.wonderful.software failed to compile once firebase moved past 10.0.0 — webpack resolved firebase/auth to its Node build (pulling in undici, whose ES2022 syntax this webpack version's parser choked on) even in client bundles. Fixed by #466: stubbed undici out via a webpack alias, since it isn't needed (native fetch is used).
  3. packages/contentsgarten failed to typecheck on the octokit v3.2.2 bump — a hand-rolled commit-author type didn't account for octokit's Record<string, never> "unknown author" case. Fixed by #467: derived the type from octokit's own listCommits return type instead.
  4. @tsconfig/node18@tsconfig/node20 surfaced a deep unexported import in micromark-extension-directive, plus a CJS/ESM default-export typing hazard in html-react-parser under Node's strict moduleResolution. Fixed by #468: fixed the import, and switched the tsup-bundled packages to moduleResolution: "bundler" (they're built by esbuild, not run directly by Node, so Node's runtime resolution rules were the wrong fit).
  5. micromark-extension-directive v4 moved Directive/Handle to the package root and narrowed Handle's return type to boolean | undefined. Fixed in the PR that bumped it.
  6. hast-util-to-string v3, hast-util-heading-rank v3, and rehype v13 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 a pnpm.overrides pin for @types/hast (two different patch versions were still coexisting and getting treated as distinct types).
  7. Turbo v1 → v2 required renaming pipeline to tasks in turbo.json (hard error in v2), fixing an invalid "version": null in contentsgarten.netlify.app/package.json, and updating turbo prune's CLI syntax. Also fixed a pre-existing gap while in there: wiki.wonderful.software's .next/** output wasn't in turbo.json's outputs list, so it was never actually being cached.
  8. Next.js 13.4.5 → 13.5.11 needed Source_Sans_Pro renamed to Source_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/*/contributors silently never matches (the bare /pages/* route swallows the request instead). Verified empirically with a throwaway smoke script before relying on it. Changed getContributors/getEditPermission's URLs from /pages/*/contributors and /pages/*/permission to /page-contributors/* and /page-permission/*.
  • Validation failures return 422, not the 400 floated 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:

BeforeAfter
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 publishnpm 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.