$ cat ~/blog
The log.
Short notes by default, an occasional deep-dive. Dumped while building.
# start here
- note #nestjs#dependency-injection#testing#typescript
A green unit suite doesn't prove your DI graph resolves
Adding a constructor dependency to a service left every unit test green — they build the class with mocked collaborators, so one more collaborator changes nothing. The tests that broke were the ones that assemble the real DI container (NestJS's `Test.createTestingModule`) and must actually resolve the new provider. If those container-level tests are the slow ones — gated behind a database or Docker and run late — a missing provider stays invisible while the fast unit suite reports all-clear. Two takeaways: a green unit run is not evidence the dependency graph wires up, and when you add a constructor dependency, grep every place that hand-builds a test module, not just the specs for the class you touched.
- deep-dive #economics#algorithms#invariants#pricing#backend
A raked winner-take-all pool can't promise small groups a fixed return multiple
Someone wanted a clean rule: whoever wins a pooled contest nets at least K× the stake they paid in. It's impossible below a field size, and no amount of pricing fixes it. If N people each stake `s` and one winner takes the pot minus a fixed cut `f` (platform fee, tax, house rake), the winner's take is at most `(1 − f)·N·s` — so `takeHome ≥ K·s` needs `N ≥ K / (1 − f)`. A 3× promise with a 30% cut is arithmetically impossible below ~5 contributors, and real per-head costs push it higher. The fix wasn't better pricing; it was to stop baking the multiple into the enforced floor and instead guarantee a healthy positive pool at any size, letting the headline multiple emerge only where the field is large enough to produce it.
$ cat a-raked-winner-take-all-pool-cant-promise-small-groups-a-fixed-return-multiple.md → - note #react#react-query#state-management#frontend#debugging
A control whose value feeds the query that supplies its own options will fight itself
Picking how many resources to book changed the job end time, the end time was part of the availability query key, the refetch marked some of the picked resources busy, and the prune that cleaned up then rewrote the count. Choose 4, land on 2.
$ cat a-control-that-feeds-its-own-options-query.md → - til #dates#dart#flutter#forms#scheduling
A default date built from now plus an offset inherits the time of day
DateTime.now().add(days: 9, hours: 9) looks like "nine days out, 9am". It is not — it is nine days out at whatever time you opened the form. Open it at 14:26 and the default start becomes 23:26.
- deep-dive #validation#backend#money#invariants#testing
A minimum-price guard computed in three places stopped being a guard
The same floor was derived at quote time, at write time and at edit time with different inputs, so the number shown, the number enforced and the number actually needed were all different. A clamp downstream turned the resulting shortfall into a clean zero.
$ cat a-floor-computed-in-three-places.md → - note #state-machines#enums#refactoring#ux
A second producer of a status turns your copy into a lie
A job could reach VOID exactly one way — an operator aborted it — so the UI hard-coded "the operator ended this job." Then I made cancelling the parent record void its job too, which was correct. Now every cancellation accused an operator who had nothing to do with it. When you make an existing status reachable from a new path, the risky sites are not the ones that *set* it — those are in your diff. They are the ones that *interpret* it: copy, icons, filters, analytics, all written when the status had one meaning. Grep for readers of the value, not writers. The tell in my case was a docstring two files away still asserting the old single cause — a stale comment is a decent proxy for stale logic.
- note #api#error-handling#debugging
An endpoint that legitimately returns nothing needs a different unwrap helper
A shared client helper unwrapped the response envelope and threw when `data` was not an object — good defaults for a fetch-one-record call. I pointed it at an endpoint whose honest answer is often `data: null` ("no pending request"), and the caller then read `["data"]` off the already-unwrapped result. So the happy path yielded null and the empty path threw: both routes to nothing. Two more swallows sat downstream — the widget read the async value with a null-coalescing accessor, and the button began with a bare `if (id == null) return`. Net effect: a whole feature was invisible and its button inert, with no error anywhere. A helper that conflates "missing" with "invalid" is wrong for any endpoint where absence is a real answer, and every silent-empty read on top of it removes another chance to notice.
- til #git#ci#linting
Attributing a lint violation: diff the branch, not your session
A file-size lint flagged a test file at 504 lines that I hadn't touched all session, so I wrote it off as inherited debt. Wrong — an earlier commit on the same branch (a prior session) had grown it from 445 over the 500-line cap, so it was mine to fix before merge. To know whether YOUR branch introduced a violation, diff the whole branch against its merge-base with the target, not just today's working set: `git log origin/main..HEAD -- path/to/file` tells you if the branch touched it, and `git show origin/main:path | wc -l` shows where it started. "I didn't edit it today" is not the same as "my branch didn't break it."
- til #github-actions#ci#git
A standing release PR re-runs its CI on every push to the base branch
Pushing a docs commit to the integration branch set off a CI run titled "promote → main", which read like a production promotion firing on its own. It wasn't: there's a long-lived open PR from the integration branch into the release branch, and every push to the integration branch (the PR's head) fires that PR's `synchronize` event, re-running its checks. The run inherits the PR's title, so a routine push looks like a release. Nothing reaches the release branch until someone explicitly merges the PR — but if you keep a standing release PR open, expect its CI to light up on every ordinary push, named as if it's shipping.
- note #concurrency#databases#transactions#backend
A fast-fail check outside the lock still needs an authoritative re-check inside it
Moving a limited-capacity booking flow from synchronous payment to an async reserve→confirm handshake reopened a race I thought I'd closed: the duplicate/capacity check that ran *before* acquiring the row lock stopped being enough. Two concurrent reserves for the same user both read "no existing booking" and both proceed. The fix isn't to delete the outside check — it's a cheap fast-fail that avoids minting an orphan payment order in the common case — it's to add a *second*, authoritative check under the pessimistic lock. The lock serialises the two transactions, so the second one now sees the first's committed hold and bails. Two tiers, each earning its place: optimistic outside, authoritative inside. Corollary that bit me the same afternoon — once a booking can sit in a "held, pending payment" state, every capacity and duplicate guard has to count held rows as occupied, or you quietly oversell during the payment window.
- til #ci#github-actions#git
A shallow git checkout silently makes CI path-filtering fail open on push
dorny/paths-filter — the GitHub Action that skips a job when its folder wasn't touched — diffs against the previous commit on push events. A default actions/checkout is shallow (depth 1), so that previous commit isn't in the clone; the filter can't compute the diff and "fails open", running *every* job even for a docs-only push. The tell is subtle: it only misbehaves on push, because pull-request events diff against the merge base, which is always present. So your PRs look correctly scoped while your branch pushes quietly run the whole matrix. One-line fix: set fetch-depth: 0 on the checkout so the history it needs to diff is actually there.
- til #firestore#security-rules#backend
A new Firestore collection needs its own security rule, or writes fail silently
Firestore security rules are matched per collection path — adding a new collection does not inherit any existing rule, even a permissive catch-all elsewhere in the file. Every write to an un-ruled collection permission-denies, and the error surfaces as a generic client-side denial with no obvious link back to "you forgot a rule block."
- deep-dive #data-modeling#debugging#firestore#schema-drift
When a filter's counts don't match, read the raw data before patching the query
A filtered view undercounted results, and querying the raw collection directly turned up two conflicting field conventions rather than a query bug — the fix was to revert to unfiltered and schedule a migration, not OR two fields together.
$ cat read-the-raw-data-before-patching-a-filter.md → - til #cloud#networking#firewall#devops
A cloud VM has two firewalls, not one
Opened a port on a managed VM, the service was listening, and it still hung from the outside. The catch: a cloud VM sits behind TWO firewalls — the provider's security group/list AND the host OS firewall (iptables/ufw). You have to open the port in both; opening one and not the other is indistinguishable from a broken server.
- note #api-design#data-modeling#backend#postgres
Add the breakdown behind the aggregate, not inside it
To add a finer-grained breakdown (per-part scores) under an existing single aggregate (one total row), the tempting move is to widen the aggregate's unique key with the new dimension. That forks its uniqueness and forces every downstream reader — rankings, exports, frozen snapshots, PDFs — to learn the new shape and re-aggregate. The cheaper design keeps the existing aggregate row as a *derived* value: recompute and upsert it from the detail rows on every write, so all existing consumers keep reading exactly one row and need zero changes. The new detail is additive and optional; rows without a breakdown stay flat. Backward-compatible by construction, and the whole read side stays untouched.
- deep-dive #caddy#tls#lets-encrypt#self-hosting#devops
Automatic HTTPS on a cheap VM with Caddy
The fastest way I've found to put a real, auto-renewing TLS cert in front of a backend on a bare VM: point a domain at the box, open two ports, and let Caddy do the ACME dance. No certbot, no cron, no renewal scripts.
$ cat automatic-https-on-a-vm-with-caddy.md → - note #react-query#caching#testing#frontend
A replace-set mutation invalidates more caches than you think
When an edit saves a set of child rows by deleting and recreating them, every child gets a brand-new id. Any client-cache query that *renders* those ids — a data-entry grid keyed by them, say — is now stale, and the next submit posts ids the server no longer knows about → 400. Invalidating the definition query you just wrote isn't enough; you have to invalidate every query that renders the regenerated ids. And this whole class of bug is invisible to unit/e2e tests that fetch fresh every time — it only reproduces when a real client holds the stale cache across the edit, so I only caught it by driving the actual UI.
- til #vite#frontend#spa#deployment
Static-site env vars are frozen at build time
Chased a 'calls the wrong API URL' bug on a deployed SPA. The URL isn't read at runtime — it's compiled into the JavaScript when you build. So changing the env var in the host's dashboard does nothing until a rebuild: on a build-on-push host you must retrigger a build, and on direct-upload there's no build step at all, so you rebuild locally and re-upload. The dashboard toggle feels like config; it's really a build input.
- deep-dive #typeorm#postgres#migrations#nestjs#refactoring
Two silent failures when you move a JSON column into child rows
Normalizing a denormalized JSON column into child rows behind an unchanged API has two failure modes that never announce themselves: a read site you forgot to re-point, and a downstream aggregation that silently ranks differently. A transient (non-persisted) typed property turns the first into a compile error; only an outcome-level regression test catches the second.
$ cat two-silent-failures-json-column-to-rows.md → - note #backend#config#nestjs#reliability
A dev default that no-ops fails silently in prod — guard it at boot
A "required env var missing → fail fast" check will not catch a config value that has a safe-looking default which quietly degrades to a no-op in production. Case in point: a notification driver that defaults to a dev channel which only logs. Nothing is missing, so validation passes — but every email in prod is dropped with no error. The fix is not another required var; it is an explicit boot-time guard: if NODE_ENV is production and the driver is still the logging default, throw. Fail-loud has to cover "present but wrong for this environment", not just "absent".
- deep-dive #design#databases#api#identifiers
When an ID is both a reference and a label, you can't just rename it
A record's human-readable ID often does double duty: a stable reference other things point at, and a label that encodes where the record lives. Move the record and those two jobs collide. The fix isn't to pick one — it's to re-mint the label and keep every old ID as an alias the lookup still resolves.
$ cat id-reference-vs-label-alias.md → - til #nestjs#typescript#validation#api
NestJS's whitelist ValidationPipe silently drops fields you didn't declare
With ValidationPipe({ whitelist: true }), any body property missing from the DTO is stripped before your handler runs — no error, no warning. So an endpoint can look like it accepts a field while doing nothing with it. To reject an unsupported field loudly you have to declare it in the DTO and throw; otherwise it just vanishes.
- til #zod#typescript#validation#config
Zod's z.coerce.boolean() turns the string "false" into true
z.coerce.boolean() runs JavaScript's Boolean(), and Boolean("false") is true — every non-empty string coerces to true. For an env flag like USE_SSL=false that silently flips the meaning to on. Parse booleans explicitly instead: z.preprocess(v => v === 'true', z.boolean()).
- til #typescript#react#dates#frontend
Deadlines from a date input: parse to local end-of-day, not UTC midnight
Wiring an <input type="date"> to a due-date field has two traps. (1) new Date('2026-07-15') parses as UTC midnight, so west of UTC it shows as the 14th in the picker — build the Date from a local-time string, new Date('2026-07-15T23:59:59'), so the day survives the round-trip. (2) Store the deadline at end-of-day, not 00:00, or a task due today is already overdue the instant the day begins. Both bugs are invisible until someone in the wrong timezone sets a date and it silently shifts a day.
- til #git#hooks#tooling
A global core.hooksPath silently disables every repo's own hooks
Setting `git config --global core.hooksPath <dir>` doesn't merge with a repo's .git/hooks — it replaces them. So a machine-wide hook quietly stops every project's own pre-commit lint, test gate, etc. from firing. The fix: point the global path at a dispatcher that, after doing its own work, chains to the repo-local hook of the same name if one exists and is executable. Also guard install against clobbering a global hooksPath someone already set.
- deep-dive #ical#rfc-5545#calendar#api#auth
The four traps in hand-rolling an .ics calendar feed
Emitting an RFC-5545 feed is mostly string-building, but four non-obvious rules bite you: all-day DTEND is exclusive, lines fold at 75 octets, TEXT values need escaping, and the clients that consume the feed can't send an auth header.
$ cat hand-rolling-an-ics-feed.md → - til #git#automation#devtools#ci
A ticket-closing git hook trips on tickets you only mention
I have a global post-commit hook that moves a ticket in-progress whenever its ID shows up in a commit message. Then a plain docs commit — "record FOO-8..12 in the changelog" — silently dragged a *finished* ticket back to in-progress, because a bare ID reads as "work happened" even when it didn't. The lesson: an auto-transition driven by an ID *mention* needs intent. Gate it on an explicit verb (`fixes`/`closes`), only ever move state forward (never re-open a done ticket), or skip docs/meta commits entirely — otherwise merely referencing an ID mutates it.
- note #data-modeling#architecture#postgres#audit
Issue a document, snapshot it — or history rewrites itself
A generated document (invoice, statement, report) is usually rendered live from current config and current inputs. The trap: once you have *issued* it to someone, editing that config later silently changes the copy they were already given. Freeze the computed output into an immutable snapshot at publish time and read from the snapshot thereafter. Two invariants make it safe: require the underlying inputs to be locked before publishing, so the frozen copy can never diverge from what produced it; and cascade-drop the snapshot on unpublish so there is exactly one source of truth at a time. Anything you hand out that is derived from mutable data needs this — otherwise your records quietly disagree with the paper you sent.
- til #macos#launchd#devtools#tcc
A launchd agent can't read ~/Desktop until you grant Full Disk Access
Ported a dev server's `make dev` into a macOS launchd agent (the systemd-unit equivalent) and it died instantly with `make: getcwd: Operation not permitted`. The cause: launchd jobs run without the TCC grants your Terminal quietly has, and `~/Desktop`, `~/Documents`, `~/Downloads` are privacy-protected — so an agent whose working directory sits under one of them is denied before it runs a line. Fix: add the launched binary (here `/usr/bin/make`) to System Settings → Privacy & Security → Full Disk Access; if its child processes still get denied, grant the child interpreter too (e.g. the `node` your build shells out to). No systemd equivalent — a Linux unit reads any path its user can — so it's an easy trap when you port a service across.
- deep-dive #prisma#multi-tenancy#postgres#security#typescript
Prisma findUnique quietly bypasses tenant scoping
A Prisma middleware that injects `where.tenantId` on every read looks airtight — until you remember `findUnique` rejects non-unique fields in its where. So `findUnique({ where: { id } })` runs unscoped and happily returns another tenant's row. The middleware never errors; it just silently does nothing. Route all tenant-scoped single lookups through `findFirst` instead.
$ cat prisma-findunique-tenant-scope-leak.md → - note #auth#multi-tenancy#architecture#security#nestjs
One API, two audiences: separate the realm, not just the role
When a single backend serves both a provider control plane and a tenant data plane, a role is not enough — roles live inside one identity space, so a scoping bug can escalate across the boundary instead of being stopped by it. Keep the two identities in separate user tables and stamp a `realm` claim on the access token; a guard rejects any token whose realm does not match the route. A tenant credential then cannot authenticate against a control-plane endpoint even if routing or role checks are misconfigured — the boundary holds by construction, not by everyone remembering to check.
- note #planning#estimation#code-audit
Treat a spec as a list of claims to disprove
Before scoping a build off a written spec, I ran a verification pass that treated every 'new feature' and every 'B depends on A' as a claim to disprove, not a task to schedule — one independent reader assigned to each. Five collapsed: things the spec called net-new were already 70–90% built under different names, and a hard 'phase 2 before phase 3' ordering was an illusion because the dependent code keyed on a plain timestamp, not the schema the plan had tied it to. A spec re-describes existing behaviour in its own vocabulary, so it systematically over-estimates the work; disproving its claims against the code first is far cheaper than building to it.
- note #tailwind#vite#css#frontend#debugging
The Tailwind config edit the build honored but the dev server ignored
Added a custom accent colour and self-hosted fonts to a Tailwind theme, but the running Vite dev server kept serving CSS with none of the new utilities — no `bg-accent`, titles falling back to the OS font — while `npm run build` was perfectly correct. The cause: Vite's Tailwind JIT doesn't reliably re-resolve `tailwind.config.js` over HMR, so utilities derived from *newly-added* theme tokens silently never get generated until you cold-restart the dev server. Worse, it hid behind fallbacks — a missing webfont looks identical to the OS mono in a screenshot. Lesson: when a Tailwind change 'isn't applying,' restart the dev server before you start editing code, and verify colours/fonts by reading computed styles off the DOM, not by eye.
- til #typescript#build#nestjs
A clean build that isn't: .tsbuildinfo outlives deleteOutDir
nest build wipes dist/ but leaves the incremental .tsbuildinfo behind. tsc then trusts the stale cache, skips files it thinks are already emitted, and the server crashes at runtime on a missing module — from a build that reported success. Dropping `incremental` fixed it, since deleteOutDir already gives you a clean build.
- til #git#tooling
A symlinked tool can vanish when its own repo switches branches
I almost installed a global CLI by symlinking it into the git repo it operates on — one source of truth, zero drift. The catch: the tool checks out other branches as part of its job, and any branch that doesn't contain the tool's folder leaves the symlink dangling — so the tool disappears whenever the repo is parked on the 'wrong' branch. Install branch-switching tooling as a copied snapshot, not a symlink into the working tree; re-copy on update. A little drift-risk beats a tool that evaporates under you.
- note #concurrency#postgres#backend#typescript
A capacity check is a race condition in disguise
Read the count, check it's under the limit, then insert — two concurrent requests both pass the check and overbook the last slot. The read-then-write is the race. Fix: take a pessimistic write lock on the row you're counting against, so the second request blocks, re-reads the now-incremented count, and fails.
- deep-dive #scheduling#algorithms#backend#typescript
Scheduling a job before you know who's in it
A greedy scheduler double-booked a user across two pipelines. The bug: downstream jobs have empty membership at scheduling time, so "reserve this job's members" reserves nobody. Fix: reserve the set of users who *could* reach the job — its transitive predecessors — not the ones literally in it yet.
$ cat scheduling-a-job-before-you-know-whos-in-it.md → - til #three.js#r3f#webgl
A billboard inside a rotated group isn't a billboard
Made a hero's glass orb scroll-reactive by rotating its group — and the portrait plane inside it, a flat billboard meant to always face the camera, sheared into noise as you scrolled. Obvious in hindsight: a camera-facing plane stops facing the camera the instant a parent transform rotates it. Keep billboards out of any rotated parent — rotate the shell, not the group holding the face. Translation and uniform scale are safe; rotation isn't.
- note #astro#view-transitions#lenis
Astro view transitions quietly break your scripts
Adding Astro's ClientRouter for page transitions broke two things silently. Bundled <script>s stop re-executing on a swap — so a form's submit handler bound on first load never attaches on a page you navigate *into*; it just does nothing. And a React island holding global state (a Lenis scroll instance) leaks a second copy per navigation, because the view-transition DOM swap bypasses React's unmount, so cleanup never runs. Fixes: rebind handlers on `astro:page-load`, and `transition:persist` any island that owns global state.
- til #cloudflare#devops#wrangler
Cloudflare Pages branch aliases are not separate projects
Deploying with `--branch x` gives you `x.<project>.pages.dev`, but it lives inside the same project. To get a clean `project-t.pages.dev` hostname you need a whole separate Pages project — and deleting the old branch deployment needs `--force` if it has an active alias.
- deep-dive #nestjs#auth#rate-limiting#jwt#debugging
A brute-force rate limit on /auth/me logged users out — then blocked re-login
Putting the whole auth controller behind a strict 10/60s throttle quietly caught /auth/me too — the endpoint the SPA hydrates on every route mount. A few clicks exhausted the budget, the 429 read as 'logged out', and the same spent budget then 429'd the re-login. The endpoints that read or rotate a cookie aren't the brute-force surface; only credential entry is.
$ cat brute-force-throttle-on-session-endpoint.md → - til #react-three-fiber#drei#three.js#webgl
In drei's glass, the immersion and the double-image are the same knob
MeshTransmissionMaterial refracts whatever you suspend inside the orb — and its `backside` pass (the second, back-hemisphere refraction that sells the 'deep inside the glass' look) is also exactly what renders a faint second copy of that object. I spent an evening trying to keep the depth while tuning the ghost away with chromaticAberration, distortion and thickness; they're coupled. You either keep `backside` and accept the occasional doubled face, or drop it and the subject flattens onto the front of the orb. Killing the ghost while keeping the depth needs a different technique (a depth-masked single refraction), not a parameter tweak.
- note #nestjs#remix#rbac#security
Hiding a button isn't access control
Made admin accounts 'management-only' on a storefront (they manage, they don't buy). It took two layers: a server-side guard that 403s any admin cart/order/wishlist write — the actual gate — and frontend hooks that hide add-to-cart/checkout and redirect admins off the shopping routes. The frontend half is pure UX; a crafted API call still has to clear the guard. Easy to conflate the two and ship only the cosmetic half. Verified both: admin gets 403 on the API, and sees zero shopping controls in the browser.
- til #make#nestjs#vite#dx
When `make stop` won't stop: the watcher is respawning your server
Spent a while baffled that `make stop` freed port 3000 yet the dev server kept logging requests. The culprit: `nest start --watch` (and `vite`/`remix vite:dev`) are watcher *parents*. Killing the process bound to the port just makes the watcher respawn a fresh child and re-bind the port. Fix: kill the watcher first — `pkill -f "nest start --watch"` — then free the ports. A bare port-kill against a live watcher is futile.
- til #git#workflow#cloudflare-pages
Sync two never-merge branches without merging — use a throwaway worktree
I keep two intentionally-diverged branches (a light theme and a terminal theme) that must never merge but should share the same content. To copy one file across without dragging presentation along: spin up a temporary `git worktree` on the other branch, `git checkout <source-branch> -- path/to/file`, commit, push, then remove the worktree. Your primary checkout never leaves its current branch the whole time — and watch out: the fresh worktree won't have your gitignored `.env`, so copy it in if that branch needs to build.
- til #three.js#r3f#webgl
R3F MeshTransmissionMaterial chokes SwiftShader
Headless Chromium falls back to SwiftShader (software GL), and a transmission orb at samples=8/detail=18 just renders black with no error. Dropping to samples=6/detail=14 keeps it alive on low-end GPUs — and always test the reduced-motion fallback separately.