Blog
The log.
Short notes and the occasional deep-dive — dumped while building.
Start here
- Deep dive #postgres#concurrency#locking#typeorm
Locking a parent row FOR UPDATE deadlocks against inserts of its children
Postgres takes a hidden FOR KEY SHARE lock on the parent whenever you insert a row whose foreign key points at it. FOR UPDATE on that parent conflicts with it, so "lock the parent first" quietly turns every child insert into a lock wait — and two transactions that each hold one side deadlock. FOR NO KEY UPDATE keeps the serialisation you wanted and lets the FK checks through.
Read more → - Note #idempotency#concurrency#postgres#backend
An idempotency key that includes a per-run id deduplicates nothing
A retry rewound a slow job and ran it again; both runs queued the same payouts. Earlier the key was (job, recipient) and the database's ON CONFLICT DO NOTHING dropped the second set. Someone added the new ledger row's id to the key to tell two lookalike buckets apart — which made every run's key unique, so the dedupe never fired again. Key on the stable thing you mean (the bucket id), not on anything a re-run regenerates.
- TIL #postgres#sql#testing
ORDER BY on a Postgres enum sorts by declaration order, not alphabetically
A test ordered rows by an enum column and expected 'fee' before 'refund'. Postgres sorts enum values in the order the type declares them, so 'refund' (declared first) came back first and the assertion failed only on a real database. Cast when you mean text order: ORDER BY type::text.
- Note #flutter#riverpod#async#testing
A widget ref used after an await can throw and skip the step that mattered
Logout cleared the session, then reset some provider state, which rebuilt the screen and unmounted the widget whose WidgetRef was doing the work. The next ref.invalidate threw "Using ref when a widget is unmounted", so the navigation to sign-in never ran and the user sat on a spinner. Only with slow storage, so instant fakes hid it. Capture the router and ProviderScope.containerOf(context) before the first await, and navigate in a finally block.
- TIL #zsh#shell#flutter#build
zsh does not word-split an unquoted variable
D="--define=A=1 --define=B=2"; build $D works in bash and in zsh passes ONE argument. My build tool took the whole string as the value of A, and B silently fell back to its default — a mobile build pointed at the wrong server. Use an array, or better, pass the settings as a file (e.g. flutter --dart-define-from-file) and grep the built binary for the value you expect.
- Deep dive #ios#swiftui#uikit#privacy#security
iOS has no FLAG_SECURE — host the app inside a secure text field's layer
Android blocks screenshots with one window flag; iOS has no equivalent. What it does have is a secure text field whose characters never appear in screenshots, recordings or mirroring. Lift that field's internal canvas out, host your whole UI inside it, and toggle isSecureTextEntry per screen.
Read more → - Note #ios#swift#networking#webrtc
NWPath can't tell you the network changed — build the identity yourself
A long-lived connection (WebRTC, a socket) must reconnect when the phone moves to a different network. Android hands you a Network object with identity. On iOS an NWPath only lists interfaces, and keying on them misses Wi-Fi A → Wi-Fi B (both en0) while firing on cellular blips. Key on the preferred interface plus the first physical one plus the gateways, and treat any return from offline as a change.
- TIL #swiftui#ios#state-management#ux
A SwiftUI alert closes on every button tap — use a sheet when state owns the question
If a state machine keeps a confirmation open through a save, and after a failure, `.alert` fights it: any tap dismisses it, so it flickers closed and reopens, Cancel can re-present it mid-save, and an error has nowhere to show. A `.sheet` bound to the same state stays put. Disable its buttons while busy, show the error inside it, and treat a swipe-away as Cancel only when the user dismissed it and no save is running.
- Note #gcp#pubsub#messaging#reliability#node
Cloud queue subscriptions quietly expire after 31 idle days
Google Cloud Pub/Sub deletes a subscription after 31 days without subscriber activity — that is the default, not an opt-in. A remote agent that was switched off for a month came back to a topic with no subscription and crashed on every start. Create per-client subscriptions with an empty expiration policy, and have the server re-ensure the topic and subscription whenever a client starts, so recovery never needs a human with a CLI.
Read more → - TIL #mongoose#mongodb#javascript#node#debugging
find() returns an empty array, so `if (!rows)` never fires
A query helper guarded "no data" with `if (!result) return []`, but Mongoose `find()` (like most ORMs list queries) resolves to `[]`, never null — the guard was dead code. The first sub-query with no rows fell through, read a field off `rows[0]` and threw, and because the results were gathered with `Promise.all`, one empty sub-query failed the whole combined response. Check `.length === 0`, and treat "no rows yet" as an expected empty result.
- TIL #batching#node#messaging#performance
A time-based batch flush has to measure the oldest item, not the newest
A sender buffered readings and flushed when "the batch is 30 s old" — but it measured age from the newest item. Every new reading reset the clock, so under a steady trickle the batch only went out when it hit the size cap: one batch in six hours on a quiet site. Age has to come from the oldest buffered item, and the check has to run on a timer, not only when something new arrives.
- Deep dive #firestore#security-rules#backend#debugging
Firestore Security Rules can't reason across documents — two ways that bites you
Two real, silent Firestore Rules failures: interpolating a reference field into a rule path instead of calling get() on it directly, and an aggregate query that fails outright (not partially) unless the rule can prove every possible matched document is readable.
Read more → - TIL #nodejs#cloud-functions#video#serverless
Reading a real MP4 duration server-side without bundling ffmpeg
A client-side video-length check is a UX guard only — a modified client can skip it. To actually enforce it, a serverless function needs the real duration, and reaching for ffmpeg-static means shipping an ~80MB binary and paying its cold-start cost on every invocation. mp4box (GPAC's own pure-JS MP4 demuxer, zero dependencies) reads the moov atom directly and hands back duration/timescale with no external binary at all.
- TIL #apt#docker#devops#debugging
A present-but-empty APT repo means the version was pruned, not misconfigured
apt update on a vendor repo reported 'No Hash entry in Release file' and 'provides only weak security information' — which reads exactly like a signing or key problem. It was not: the InRelease file existed (HTTP 200) but the package index behind it was empty, because the vendor had removed that end-of-life major version from every distribution. No repo or key tweak brings a pruned version back. To install an exact EOL version, pull its pinned Docker image instead of chasing the apt package.
- TIL #gcp#gcloud#ssh#devops
gcloud compute scp lands files as a different user than your SSH shell
gcloud compute scp copied three files to vm:~/ and reported 100% success, but ls ~ in my interactive gcloud compute ssh session showed an empty directory. gcloud derives its own SSH username from your account / OS Login, which is not necessarily the user your interactive shell runs as — so the two ~ resolved to different home directories. The files were fine, just under /home/<other-user>/; sudo find /home -name '<file>' located them. Lesson: never assume the scp user and the login user are the same account.
- TIL #apt#gpg#linux#devops#debugging
A signed-by APT repo reads only the one keyring it names
Added a third-party APT repo on Ubuntu 22.04, imported its GPG key into /etc/apt/trusted.gpg.d/, and apt still failed with NO_PUBKEY — even though gpg --show-keys proved the key was valid and present. The repo's sources line pinned [signed-by=/usr/share/keyrings/vendor.gpg], and a signed-by repo trusts ONLY that exact file; keys in trusted.gpg.d are ignored for it. The fix was to write the key to the path signed-by names. That scoping is the whole point of signed-by: a key in trusted.gpg.d would authenticate every repo on the box, not just this one.
- Deep dive #packaging#dependencies#freebsd#debugging#devops#sat-solver
One unsatisfiable dependency made pkg silently drop five others
A package I published declared a dependency that had never been published. Rather than fail, the solver relaxed the problem, produced a plan quietly missing five *unrelated* dependencies, installed it, and exited 0 — and the breakage surfaced nowhere near the cause.
Read more → - 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.
Read more → - 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.
Read more → - 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.
Read more → - 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.
Read more → - 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.
Read more → - 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.
Read more → - 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.
Read more → - 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.
Read more → - 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.
Read more → - 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.
Read more → - 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.
- 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.
Read more → - 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.
- 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.
No posts match that filter.