Yesterday’s post was about a 2,647-line plan that forgot to wire the page loaders to the new trip resolver. Today the v0.2 of OurBudgetTracker actually deployed — multi-trip, subcategories, per-trip members, the whole thing. The merge commit landed, the Quadlet tag bumped to 0.2.0, deployment.md grew a v0.2 section. From the outside it looks like a clean rollout.
It wasn’t. There was a build error in between, and the story of where it came from is worth a post on its own. It is the kind of error I keep filing under “things the test suite cannot, by construction, see.”
The plan said “drawer actions”
The Drawer is the trip-switcher in the app shell. It lists active trips, archived trips, and lets you click one to switch. Two server actions back it: switchTrip (set the user’s last-active trip to the one they picked) and unarchiveAndSwitch (flip a trip out of archived state and then switch to it). Both are tiny, both touch the same trips library, both get invoked the same way from the same form.
The Drawer renders inside +layout.svelte, because it has to be present on every route. So the obvious place to put the actions backing it is +layout.server.ts — the layout’s server companion. That’s where the layout’s load function already lived. The pattern feels symmetric: layout component, layout loader, layout actions.
That symmetry is a lie. SvelteKit does not allow it.
Where the build broke
I learned this from the server. The local development cycle on this app is vitest for unit and integration tests, plus tsc --noEmit to check types. Both passed clean. I pushed, walked over to the Outpost runner on server01, watched the build kick off, and got this back:
Error: Invalid export 'actions' in src/routes/+layout.server.ts
('actions' is a valid export in +page.server.ts)
That is a vite build error — specifically, it’s a SvelteKit semantic validation that runs during the build’s prerender / analyse phase. The plugin walks the route tree, looks at every +layout.server.ts and +page.server.ts, and rejects exports that aren’t legal for the file type they’re in. actions is page-only. The build refuses to continue.
The fix took five minutes. Move the two action handlers into routes/+page.server.ts (the root page’s server file), drop the now-unused imports out of the layout, and leave a comment explaining why the actions are not where you’d intuitively look for them. The Drawer’s form was already posting to action="/?/switchTrip", and /?/switchTrip resolves to the root page’s named action regardless — so the move was a no-op from the user’s side. Nothing in the Drawer component had to change. Commit 9c03149.
Why my local checks missed it
This is the part I want to dwell on, because I have done it before and I will do it again unless I get more deliberate about what I’m running.
Vitest doesn’t run the build. Vitest exercises individual modules, mocks what it needs, and reports back. None of the tests I wrote for trip-members, categories, resolveCurrentTrip, or even the form-action handlers themselves involved spinning up the SvelteKit build pipeline. They imported the action handlers as plain functions, fed them a fake request and locals, and asserted on the return value. From vitest’s perspective, an action handler in +layout.server.ts looks exactly like an action handler in +page.server.ts. The validation that distinguishes them lives in a vite plugin that vitest never loads.
tsc --noEmit doesn’t enforce SvelteKit rules. TypeScript checked that my Actions type was correctly used, that request.formData() returned the right thing, that setLastActive’s arguments matched the imported signature. All of that was fine. The TypeScript compiler has no opinion on which kinds of SvelteKit conventions belong in which files. “You cannot export actions from a layout server file” is a SvelteKit rule, not a TypeScript rule. The ./$types types are generated by SvelteKit’s sync step in a way that would have refused to produce an Actions type for +layout.server.ts — but I didn’t run svelte-kit sync between writing the code and running tests, and my Actions import in the layout file resolved to a generic shape that didn’t trip anything.
The Quadlet build runner caught it because it has to. The runner on server01 doesn’t pick and choose phases. It runs npm ci, it runs vite build, it builds the Podman image, it boots the systemd unit. vite build is the first place the actual SvelteKit route conventions get exercised end-to-end against my code. So the runner is the first thing that ever saw the bug.
What I take away
The lesson is the same one that came out of the DR-script post a few days ago, and the loaders post yesterday, and probably every “I thought it worked” post I’ve written: the test you ran is only as good as the surface it exercised.
Vitest is a fast, sharp tool. It finds wrong-shape return values, broken database calls, off-by-one date math. It does not find rule-of-the-framework violations, because it doesn’t load the framework’s validation passes. tsc --noEmit finds type errors. It does not find anything that is type-clean but semantically illegal under a particular framework’s conventions. Together they cover a lot. They do not cover this.
There is a real and easy answer here: I should be running npm run build locally on this app before I push, not just npm run test and npm run typecheck. vite build is fast — under thirty seconds on this app — and it executes the exact same plugin chain that the Outpost runner uses. Adding it to the pre-push checklist would have caught this in three minutes of feedback loop instead of however long the push-watch-fail-fix-rebuild cycle takes when the failure is on a remote server. That’s the change I’ll make next time I touch this app.
The deeper version of the lesson is something like: “for any framework, find the smallest command that loads the framework’s full rule set, and treat that command as the floor of what passes for verification.” vite build is that command for SvelteKit. cargo check is that command for Rust. pytest --collect-only plus a real pytest run is the closest equivalent for most Python projects, and even then it doesn’t catch import-time problems that depend on production config. Each framework has a floor. Vitest and tsc --noEmit were not the floor for this one.
Sidebar — the day’s other notes from the digest. Tonight’s research run flagged a new Ceph slow-op alert on osd.2. That’s a different OSD than the Silicon Power UD90 pattern Homelab #261 has been tracking on osd.1, so it gets a passive 24–48h watch rather than an immediate ticket. All three OSDs are still up+in, the cluster has 3.8 TiB free of 4.2 TiB, and the 96 PGs are active+clean. Kernel CVE backports for “Copy Fail” (CVE-2026-31431) and “Dirty Frag” (CVE-2026-43284) were verified against the running rpm changelogs on kvm01 and storage01 — both already patched. And site02-kvm01 is on day 14 of the 30-day clean-uptime watch from Homelab #252, still reporting cleanly into Wazuh. A quiet night, which is the kind of night the rest of the lab earns by being noisy on other days.
