PageSourceSearch

https://martyw.dev/notes/

html martyw.dev collected 2026-09-25 22:44:24 UTC 60,175 bytes, 165 lines download raw bytes

1<!DOCTYPE html><html lang="en-AU"> <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Notes — Marty</title><meta name="description" content="Short-form notes, showerthoughts, and quick observations."><meta name="author" content="Marty"><link rel="canonical" href="https://martyw.dev/notes/"><meta property="og:type" content="website"><meta property="og:site_name" content="martyw.dev"><meta property="og:locale" content="en_AU"><meta property="og:title" content="Notes"><meta property="og:description" content="Short-form notes, showerthoughts, and quick observations."><meta property="og:url" content="https://martyw.dev/notes/"><meta property="og:image" content="https://martyw.dev/og-default.png"><meta property="og:image:type" content="image/png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630"><meta property="og:image:alt" content="martyw.dev — Things I make, learn, and think about."><meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="Notes"><meta name="twitter:description" content="Short-form notes, showerthoughts, and quick observations."><meta name="twitter:image" content="https://martyw.dev/og-default.png"><meta name="twitter:image:alt" content="martyw.dev — Things I make, learn, and think about."><link rel="icon" href="/favicon.svg" type="image/svg+xml"><link rel="alternate" type="application/rss+xml" title="Marty — RSS" href="/rss.xml"><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&display=swap" media="print" onload="this.media='all'">
1<script>
2      (function () {
3        try {
4          const stored = localStorage.getItem("theme");
5          const prefersDark = window.matchMedia(
6            "(prefers-color-scheme: dark)",
7          ).matches;
8          const mode = stored ?? (prefersDark ? "dark" : "light");
9          if (mode === "dark") document.documentElement.classList.add("dark");
10        } catch (_) {}
11      })();
12    </script>
12<link rel="stylesheet" href="/_astro/_slug_.9N3vsjBc.css">
13<style>.tag-list[data-astro-cid-lga65v7i][data-collapsible] .tag-list__hidden[data-astro-cid-lga65v7i]{display:none}.tag-list[data-astro-cid-lga65v7i][data-collapsible]:hover .tag-list__hidden[data-astro-cid-lga65v7i],.tag-list[data-astro-cid-lga65v7i][data-collapsible][data-expanded] .tag-list__hidden[data-astro-cid-lga65v7i],.tag-list[data-astro-cid-lga65v7i][data-collapsible]:focus-within .tag-list__hidden[data-astro-cid-lga65v7i]{display:list-item}.tag-list[data-astro-cid-lga65v7i][data-collapsible]:hover .tag-list__more[data-astro-cid-lga65v7i],.tag-list[data-astro-cid-lga65v7i][data-collapsible][data-expanded] .tag-list__more[data-astro-cid-lga65v7i]{display:none}
14</style></head> <body class="flex flex-col min-h-dvh"> <header class="border-b border-[color:var(--border)] bg-[color:var(--bg)]/80 backdrop-blur sticky top-0 z-20"> <div class="container-wide flex items-center justify-between gap-4 py-4"> <a href="/" class="font-semibold text-lg tracking-tight no-underline text-[color:var(--ink)] hover:text-[color:var(--accent)]">
15martyw<span class="text-[color:var(--accent)]">.</span>dev
16</a> <nav class="hidden sm:flex items-center gap-1 text-sm"> <a href="/" class="px-3 py-1.5 rounded-md no-underline text-[color:var(--ink-soft)] hover:text-[color:var(--ink)] hover:bg-[color:var(--bg-soft)]"> Home </a><a href="/posts/" class="px-3 py-1.5 rounded-md no-underline text-[color:var(--ink-soft)] hover:text-[color:var(--ink)] hover:bg-[color:var(--bg-soft)]"> Posts </a><a href="/notes/" class="px-3 py-1.5 rounded-md no-underline text-[color:var(--accent-strong)] bg-[color:var(--accent-soft)]"> Notes </a><a href="/about/" class="px-3 py-1.5 rounded-md no-underline text-[color:var(--ink-soft)] hover:text-[color:var(--ink)] hover:bg-[color:var(--bg-soft)]"> About </a> </nav> <div class="flex items-center gap-2"> <button type="button" data-search-open class="inline-flex items-center justify-center rounded-md w-9 h-9 border border-[color:var(--border)] bg-[color:var(--surface)] hover:bg-[color:var(--bg-soft)] transition-colors" aria-label="Search (press /)" title="Search — press /"> <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <circle cx="11" cy="11" r="7"></circle> <path d="m20 20-3.5-3.5"></path> </svg> </button> <button id="theme-toggle" type="button" aria-label="Toggle colour theme" class="inline-flex items-center justify-center rounded-md w-9 h-9 border border-[color:var(--border)] bg-[color:var(--surface)] hover:bg-[color:var(--bg-soft)] transition-colors"> <svg class="theme-icon-light w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <circle cx="12" cy="12" r="4"></circle> <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"></path> </svg> <svg class="theme-icon-dark w-4 h-4 hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path> </svg> </button> 
16<script>
17  (() => {
18    const root = document.documentElement;
19    const btn = document.getElementById("theme-toggle");
20    if (!btn) return;
21
22    const apply = (mode) => {
23      if (mode === "dark") {
24        root.classList.add("dark");
25        document.querySelector(".theme-icon-light")?.classList.add("hidden");
26        document.querySelector(".theme-icon-dark")?.classList.remove("hidden");
27      } else {
28        root.classList.remove("dark");
29        document.querySelector(".theme-icon-light")?.classList.remove("hidden");
30        document.querySelector(".theme-icon-dark")?.classList.add("hidden");
31      }
32    };
33
34    apply(root.classList.contains("dark") ? "dark" : "light");
35
36    btn.addEventListener("click", () => {
37      const next = root.classList.contains("dark") ? "light" : "dark";
38      localStorage.setItem("theme", next);
39      apply(next);
40    });
41  })();
42</script>
42 </div> </div> <nav class="sm:hidden border-t border-[color:var(--border)] text-sm"> <div class="container-wide flex items-center gap-1 overflow-x-auto py-2"> <a href="/" class="px-3 py-1 rounded-md no-underline whitespace-nowrap text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"> Home </a><a href="/posts/" class="px-3 py-1 rounded-md no-underline whitespace-nowrap text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"> Posts </a><a href="/notes/" class="px-3 py-1 rounded-md no-underline whitespace-nowrap text-[color:var(--accent-strong)] bg-[color:var(--accent-soft)]"> Notes </a><a href="/about/" class="px-3 py-1 rounded-md no-underline whitespace-nowrap text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"> About </a> </div> </nav> </header> <main class="flex-1" id="main">  <div class="bg-[color:var(--bg-soft)] border-b border-[color:var(--border)]"> <header class="container-prose py-10 sm:py-12"> <h1 class="text-3xl sm:text-4xl font-semibold tracking-tight mb-2">Notes</h1> <p class="text-[color:var(--ink-soft)]">
43Short-form — showerthoughts, links, observations, things that don't need a whole post.
44</p> </header> </div> <section class="container-prose pt-12 pb-16">  <article class="py-6 border-b border-[color:var(--border)] last:border-b-0">  <div class="prose prose-sm max-w-none"> <p>TIL that <code>CREATE TEMPORARY TABLE</code> in Postgres takes an <code>ON COMMIT</code> clause:</p>
45<pre class="astro-code astro-code-themes vitesse-light vitesse-dark" style="background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="sql"><code><span class="line"><span style="color:#1E754F;--shiki-dark:#4D9375">CREATE</span><span style="color:#393A34;--shiki-dark:#DBD7CAEE"> TEMP </span><span style="color:#1E754F;--shiki-dark:#4D9375">TABLE</span><span style="color:#393A34;--shiki-dark:#DBD7CAEE"> staging (id </span><span style="color:#AB5959;--shiki-dark:#CB7676">bigint</span><span style="color:#393A34;--shiki-dark:#DBD7CAEE">, payload jsonb) </span><span style="color:#1E754F;--shiki-dark:#4D9375">ON</span><span style="color:#1E754F;--shiki-dark:#4D9375"> COMMIT</span><span style="color:#1E754F;--shiki-dark:#4D9375"> DROP</span><span style="color:#393A34;--shiki-dark:#DBD7CAEE">;</span></span></code></pre>
46<p>Three settings. <code>PRESERVE ROWS</code> is the default and keeps the table around for
47the whole session. <code>DELETE ROWS</code> truncates it at every commit. <code>DROP</code> bins the
48table itself at the end of the transaction that created it, which is almost
49always what I actually wanted — a scratch table for one bulk load or one gnarly
50multi-step query, gone the moment I’m done with it. No cleanup step, no
51<code>DROP TABLE IF EXISTS</code> at the top guarding against whatever the last run left
52lying around.</p>
53<p>Postgres deviates from the standard here, incidentally: SQL says the default
54should be <code>DELETE ROWS</code>, and Postgres went with <code>PRESERVE ROWS</code>.</p>
55<p>The thing that made it click was PgBouncer’s feature map, which lists
56<code>ON COMMIT DROP</code> temp tables as working under transaction pooling and
57<code>PRESERVE/DELETE ROWS</code> as “Never”. Of course it does — in transaction pooling
58the server connection goes back in the pool after each commit, so anything
59session-scoped either leaks into someone else’s transaction or vanishes.
60Scope the table to the transaction and there’s nothing left to leak.</p> </div> <footer class="mt-4 flex items-center justify-between gap-4 text-xs text-[color:var(--ink-muted)]"> <div class="min-w-0"> <ul class="tag-list flex flex-wrap gap-1.5 not-prose" data-collapsible data-astro-cid-lga65v7i><li data-astro-cid-lga65v7i><button type="button" data-search-tag="software" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag software" data-astro-cid-lga65v7i>
61#software</button></li><li class="tag-list__more" data-astro-cid-lga65v7i><button type="button" class="pill px-2 py-0.5 text-xs" aria-expanded="false" aria-label="Show 1 more tag" data-astro-cid-lga65v7i>
62+ 1 more
63</button></li><li class="tag-list__hidden" data-astro-cid-lga65v7i><button type="button" data-search-tag="databases" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag databases" tabindex="-1" data-astro-cid-lga65v7i>
64#databases</button></li></ul>
64<script type="module">document.querySelectorAll(".tag-list[data-collapsible]").forEach(e=>{const t=e.querySelector(".tag-list__more button");t&&t.addEventListener("click",a=>{a.stopPropagation(),e.hasAttribute("data-expanded")?(e.removeAttribute("data-expanded"),t.setAttribute("aria-expanded","false")):(e.setAttribute("data-expanded",""),t.setAttribute("aria-expanded","true"),e.querySelectorAll(".tag-list__hidden button").forEach(r=>r.removeAttribute("tabindex")))})});</script>
64 </div> <a href="/notes/postgres-on-commit-drop/" class="shrink-0 no-underline text-[color:var(--ink-muted)] hover:text-[color:var(--accent)]" aria-label="Permalink to note from 3 September 2026"> <time datetime="2026-09-03T17:04:00.000Z">3 September 2026</time> <span class="ml-1.5" aria-hidden="true">#</span> </a> </footer> </article><article class="py-6 border-b border-[color:var(--border)] last:border-b-0">  <div class="prose prose-sm max-w-none"> <p>Threw together <a href="https://relink.martyw.dev/" rel="noopener noreferrer" target="_blank">Relink Codex</a> — playstyles, combo
65routes, weapons and sigil loadouts for all 28 characters in <em>Granblue Fantasy:
66Relink</em>.</p>
67<p>Two things collided. The expansion pulled me back into the game, I’m somewhere
68around Proud and trying to learn characters I’ve never touched, and the usual way
69to do that is trawling Reddit threads and pausing YouTube videos on the frame
70where the actual information is. I didn’t fancy it. The other thing is that I pay
71for Claude Max every month and reliably don’t get near the token allowance, which
72is its own quiet kind of waste — the plan costs the same whether I spend it or
73not.</p>
74<p>So the site is less a project than the output of a research task I didn’t want to
75do by hand. Three power brackets rather than one canonical build, because a
76loadout that’s right at 12,000 PWR is wrong by the time Endless Ragnarok has you
77at 40,000. Astro, static, no backend.</p>
78<p>It’s current as of patch 2.0.2 and will quietly stop being true the moment
79Cygames ships another one. But re-running the research costs me nothing I wasn’t
80already paying, which was most of the point.</p> </div> <footer class="mt-4 flex items-center justify-between gap-4 text-xs text-[color:var(--ink-muted)]"> <div class="min-w-0"> <ul class="tag-list flex flex-wrap gap-1.5 not-prose" data-collapsible data-astro-cid-lga65v7i><li data-astro-cid-lga65v7i><button type="button" data-search-tag="gaming" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag gaming" data-astro-cid-lga65v7i>
81#gaming</button></li><li class="tag-list__more" data-astro-cid-lga65v7i><button type="button" class="pill px-2 py-0.5 text-xs" aria-expanded="false" aria-label="Show 3 more tags" data-astro-cid-lga65v7i>
82+ 3 more
83</button></li><li class="tag-list__hidden" data-astro-cid-lga65v7i><button type="button" data-search-tag="web" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag web" tabindex="-1" data-astro-cid-lga65v7i>
84#web</button></li><li class="tag-list__hidden" data-astro-cid-lga65v7i><button type="button" data-search-tag="software" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag software" tabindex="-1" data-astro-cid-lga65v7i>
85#software</button></li><li class="tag-list__hidden" data-astro-cid-lga65v7i><button type="button" data-search-tag="ai" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag ai" tabindex="-1" data-astro-cid-lga65v7i>
86#ai</button></li></ul> </div> <a href="/notes/relink-codex/" class="shrink-0 no-underline text-[color:var(--ink-muted)] hover:text-[color:var(--accent)]" aria-label="Permalink to note from 5 August 2026"> <time datetime="2026-08-05T20:50:00.000Z">5 August 2026</time> <span class="ml-1.5" aria-hidden="true">#</span> </a> </footer> </article><article class="py-6 border-b border-[color:var(--border)] last:border-b-0">  <div class="prose prose-sm max-w-none"> <p>Summer Game Fest dropped on Friday and somehow the lineup read like it was
87scraped straight from my wishlist. Three of my favourite franchises, all in one
88show, all within a couple of hours of each other. I keep waiting for the catch.</p>
89<p>Code Veronica is finally getting the remake treatment — retitled just <em>Resident
90Evil: Veronica</em>, Claire back as the lead, the reveal opening on a rainy Paris
91apartment block. It’s been the obvious gap in Capcom’s remake run for years and
92they’ve finally filled it. 2027, and on Switch 2 alongside the usual platforms.</p>
93<p>Then FF7 Remake Part 3 turned out to be <em>Final Fantasy 7 Revelation</em> — Hamaguchi
94on stage, the Highwind in your hands, skydiving into an open world, job classes
95in combat. Spring 2027. The trilogy actually lands.</p>
96<p>And the one I didn’t see coming: <em>Stellar Blade: Blood Rain</em>. New protagonist —
97Evie, not Eve — swapping the sword for gauntlet brawling, and Shift Up
98self-publishing this time so it’s not a PlayStation exclusive. Still early, but
99pointed at 2027 too.</p>
100<p>So that’s my three most-anticipated games sharing a release year and a single
101showcase. Either the universe owes me one or 2027 is going to cost me a fortune.</p> </div> <footer class="mt-4 flex items-center justify-between gap-4 text-xs text-[color:var(--ink-muted)]"> <div class="min-w-0"> <ul class="tag-list flex flex-wrap gap-1.5 not-prose" data-astro-cid-lga65v7i><li data-astro-cid-lga65v7i><button type="button" data-search-tag="gaming" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag gaming" data-astro-cid-lga65v7i>
102#gaming</button></li></ul> </div> <a href="/notes/summer-game-fest-2026-perfect-lineup/" class="shrink-0 no-underline text-[color:var(--ink-muted)] hover:text-[color:var(--accent)]" aria-label="Permalink to note from 7 June 2026"> <time datetime="2026-06-07T20:09:00.000Z">7 June 2026</time> <span class="ml-1.5" aria-hidden="true">
102#</span> </a> </footer> </article><article class="py-6 border-b border-[color:var(--border)] last:border-b-0">  <div class="prose prose-sm max-w-none"> <p>Spent the week deciding on a physics engine for the game engine and landed on
103<a href="https://rapier.rs/" rel="noopener noreferrer" target="_blank">Rapier</a>. It’s written in Rust and ships to the browser as
104WASM, which sounds like overkill for a 2D hobby engine until you watch the
105alternatives fall over.</p>
106<p>PhysicsJS was the first I crossed off — last meaningful release was years ago,
107and I’m not building on something nobody’s touched since the previous decade.
108Matter.js was the real contender: pure JS, easy to drop in, lovely docs, and a
109big enough community that every problem I’d hit has already been answered. But
110the maintenance has gone quiet, and once I started pushing body counts the
111frame budget got tight in a way I couldn’t profile my way out of.</p>
112<p>Rapier wins on the two things I actually care about. It’s fast — the WASM core
113doesn’t blink at the body counts that made Matter.js sweat — and it’s
114deterministic across platforms, which matters the moment you think about
115networking or replays. The cost is a heavier, less JS-native API and a WASM
116blob to load. Worth it. I’d rather pay that upfront than fight the physics
117layer every time the simulation needs to grow.</p> </div> <footer class="mt-4 flex items-center justify-between gap-4 text-xs text-[color:var(--ink-muted)]"> <div class="min-w-0"> <ul class="tag-list flex flex-wrap gap-1.5 not-prose" data-collapsible data-astro-cid-lga65v7i><li data-astro-cid-lga65v7i><button type="button" data-search-tag="software" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag software" data-astro-cid-lga65v7i>
118#software</button></li><li class="tag-list__more" data-astro-cid-lga65v7i><button type="button" class="pill px-2 py-0.5 text-xs" aria-expanded="false" aria-label="Show 3 more tags" data-astro-cid-lga65v7i>
119+ 3 more
120</button></li><li class="tag-list__hidden" data-astro-cid-lga65v7i><button type="button" data-search-tag="gaming" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag gaming" tabindex="-1" data-astro-cid-lga65v7i>
121#gaming</button></li><li class="tag-list__hidden" data-astro-cid-lga65v7i><button type="button" data-search-tag="javascript" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag javascript" tabindex="-1" data-astro-cid-lga65v7i>
122#javascript</button></li><li class="tag-list__hidden" data-astro-cid-lga65v7i><button type="button" data-search-tag="web" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag web" tabindex="-1" data-astro-cid-lga65v7i>
123#web</button></li></ul> </div> <a href="/notes/rapier-for-the-game-engine/" class="shrink-0 no-underline text-[color:var(--ink-muted)] hover:text-[color:var(--accent)]" aria-label="Permalink to note from 4 June 2026"> <time datetime="2026-06-04T10:01:00.000Z">4 June 2026</time> <span class="ml-1.5" aria-hidden="true">#</span> </a> </footer> </article><article class="py-6 border-b border-[color:var(--border)] last:border-b-0">  <div class="prose prose-sm max-w-none"> <p>The comments on here are down right now. They run on <a href="https://cusdis.com/" rel="noopener noreferrer" target="_blank">Cusdis</a> — open-source, on their free hosted tier, and the most lightweight option I could find. I’ve used a few commenting systems over the years — FastComments, Disqus, that lot — and Cusdis was the leanest by a distance: no tracker payload, no account wall, just a textbox. The catch with lightweight-and-free is that when the hosted instance falls over, my comments fall over with it, and there’s nothing to do but wait.</p>
124<p>Which is fine. Comments were never the point of this place, and only a handful of posts ever get any. But it’s a tidy illustration of what you sign up for when you lean on someone else’s free service: you’ve outsourced a feature <em>and</em> its uptime. I could write my own in an afternoon — it’s a form, a table, and a bit of moderation — but then the uptime would be mine too, and that’s the part nobody misses until it’s theirs.</p> </div> <footer class="mt-4 flex items-center justify-between gap-4 text-xs text-[color:var(--ink-muted)]"> <div class="min-w-0"> <ul class="tag-list flex flex-wrap gap-1.5 not-prose" data-collapsible data-astro-cid-lga65v7i><li data-astro-cid-lga65v7i><button type="button" data-search-tag="meta" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag meta" data-astro-cid-lga65v7i>
125#meta</button></li><li class="tag-list__more" data-astro-cid-lga65v7i><button type="button" class="pill px-2 py-0.5 text-xs" aria-expanded="false" aria-label="Show 3 more tags" data-astro-cid-lga65v7i>
126+ 3 more
127</button></li><li class="tag-list__hidden" data-astro-cid-lga65v7i><button type="button" data-search-tag="software" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag software" tabindex="-1" data-astro-cid-lga65v7i>
128#software</button></li><li class="tag-list__hidden" data-astro-cid-lga65v7i><button type="button" data-search-tag="web" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag web" tabindex="-1" data-astro-cid-lga65v7i>
129#web</button></li><li class="tag-list__hidden" data-astro-cid-lga65v7i><button type="button" data-search-tag="tools" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag tools" tabindex="-1" data-astro-cid-lga65v7i>
130#tools</button></li></ul> </div> <a href="/notes/comments-are-down-thats-the-deal/" class="shrink-0 no-underline text-[color:var(--ink-muted)] hover:text-[color:var(--accent)]" aria-label="Permalink to note from 1 June 2026"> <time datetime="2026-06-01T11:08:00.000Z">1 June 2026</time> <span class="ml-1.5" aria-hidden="true">#</span> </a> </footer> </article><article class="py-6 border-b border-[color:var(--border)] last:border-b-0">  <div class="prose prose-sm max-w-none"> <p>Two years after <a href="/posts/javascript-game-development-engine-ideas/">writing up some ideas</a> for a JavaScript game engine, I’ve finally started building it. Early days, but it lives at <a href="https://arcade2d.com/" rel="noopener noreferrer" target="_blank">arcade2d.com</a>.</p> </div> <footer class="mt-4 flex items-center justify-between gap-4 text-xs text-[color:var(--ink-muted)]"> <div class="min-w-0"> <ul class="tag-list flex flex-wrap gap-1.5 not-prose" data-collapsible data-astro-cid-lga65v7i><li data-astro-cid-lga65v7i><button type="button" data-search-tag="software" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag software" data-astro-cid-lga65v7i>
131#software</button></li><li class="tag-list__more" data-astro-cid-lga65v7i><button type="button" class="pill px-2 py-0.5 text-xs" aria-expanded="false" aria-label="Show 3 more tags" data-astro-cid-lga65v7i>
132+ 3 more
133</button></li><li class="tag-list__hidden" data-astro-cid-lga65v7i><button type="button" data-search-tag="gaming" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag gaming" tabindex="-1" data-astro-cid-lga65v7i>
134#gaming</button></li><li class="tag-list__hidden" data-astro-cid-lga65v7i><button type="button" data-search-tag="javascript" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag javascript" tabindex="-1" data-astro-cid-lga65v7i>
135#javascript</button></li><li class="tag-list__hidden" data-astro-cid-lga65v7i><button type="button" data-search-tag="web" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag web" tabindex="-1" data-astro-cid-lga65v7i>
136#web</button></li></ul> </div> <a href="/notes/arcade2d-finally-started/" class="shrink-0 no-underline text-[color:var(--ink-muted)] hover:text-[color:var(--accent)]" aria-label="Permalink to note from 28 May 2026"> <time datetime="2026-05-28T10:00:00.000Z">28 May 2026</time> <span class="ml-1.5" aria-hidden="true">#</span> </a> </footer> </article><article class="py-6 border-b border-[color:var(--border)] last:border-b-0">  <div class="prose prose-sm max-w-none"> <p>New site, new shape. The old one was a developer-CV-with-blog. This one is broader — software, hardware, AI, the homestead, gaming, the occasional showerthought. Short stuff lives here in Notes; longer pieces under Posts.</p>
137<p>Built with Astro and Tailwind on Cloudflare Workers. Most of the wiring — components, porting old articles, even the voice this note is written in — went through Claude Code. Worth a post of its own eventually.</p> </div> <footer class="mt-4 flex items-center justify-between gap-4 text-xs text-[color:var(--ink-muted)]"> <div class="min-w-0"> <ul class="tag-list flex flex-wrap gap-1.5 not-prose" data-collapsible data-astro-cid-lga65v7i><li data-astro-cid-lga65v7i><button type="button" data-search-tag="meta" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag meta" data-astro-cid-lga65v7i>
138#meta</button></li><li class="tag-list__more" data-astro-cid-lga65v7i><button type="button" class="pill px-2 py-0.5 text-xs" aria-expanded="false" aria-label="Show 2 more tags" data-astro-cid-lga65v7i>
139+ 2 more
140</button></li><li class="tag-list__hidden" data-astro-cid-lga65v7i><button type="button" data-search-tag="ai" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag ai" tabindex="-1" data-astro-cid-lga65v7i>
141#ai</button></li><li class="tag-list__hidden" data-astro-cid-lga65v7i><button type="button" data-search-tag="showerthought" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag showerthought" tabindex="-1" data-astro-cid-lga65v7i>
142#showerthought</button></li></ul> </div> <a href="/notes/new-site-new-shape/" class="shrink-0 no-underline text-[color:var(--ink-muted)] hover:text-[color:var(--accent)]" aria-label="Permalink to note from 28 May 2026"> <time datetime="2026-05-28T09:00:00.000Z">28 May 2026</time> <span class="ml-1.5" aria-hidden="true">
142#</span> </a> </footer> </article><article class="py-6 border-b border-[color:var(--border)] last:border-b-0">  <div class="prose prose-sm max-w-none"> <p>The instinct when something’s slow is to put Redis in front of it. Sometimes that’s right. Often it’s a way to defer thinking about the actual problem, with the added bonus of now having two: the original slowness <em>plus</em> a cache that drifts out of sync with the truth in ways you can’t predict.</p>
143<p>The question that filters most “let’s add a cache” proposals: how does it get invalidated? If the answer is “TTL and we’ll cross our fingers”, you’re not designing a cache, you’re rate-limiting how often your bug appears. A cache without a clear purging story — what triggers it, what gets purged, what happens when purging fails — is a wager that staleness will hurt less than slowness. Sometimes true. Rarely measured.</p> </div> <footer class="mt-4 flex items-center justify-between gap-4 text-xs text-[color:var(--ink-muted)]"> <div class="min-w-0"> <ul class="tag-list flex flex-wrap gap-1.5 not-prose" data-collapsible data-astro-cid-lga65v7i><li data-astro-cid-lga65v7i><button type="button" data-search-tag="software" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag software" data-astro-cid-lga65v7i>
144#software</button></li><li class="tag-list__more" data-astro-cid-lga65v7i><button type="button" class="pill px-2 py-0.5 text-xs" aria-expanded="false" aria-label="Show 1 more tag" data-astro-cid-lga65v7i>
145+ 1 more
146</button></li><li class="tag-list__hidden" data-astro-cid-lga65v7i><button type="button" data-search-tag="web" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag web" tabindex="-1" data-astro-cid-lga65v7i>
147#web</button></li></ul> </div> <a href="/notes/caches-need-an-invalidation-story/" class="shrink-0 no-underline text-[color:var(--ink-muted)] hover:text-[color:var(--accent)]" aria-label="Permalink to note from 29 August 2025"> <time datetime="2025-08-29T09:00:00.000Z">29 August 2025</time> <span class="ml-1.5" aria-hidden="true">#</span> </a> </footer> </article><article class="py-6 border-b border-[color:var(--border)] last:border-b-0">  <div class="prose prose-sm max-w-none"> <p>A common shape: fetch a flat list of rows, then group them in application code into a <code>Map&#x3C;parentId, Row[]></code> before returning. It works, it’s familiar, and it’s nearly always slower and more memory-hungry than asking the database to do it.</p>
148<pre class="astro-code astro-code-themes vitesse-light vitesse-dark" style="background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="sql"><code><span class="line"><span style="color:#1E754F;--shiki-dark:#4D9375">SELECT</span></span>
149<span class="line"><span style="color:#393A34;--shiki-dark:#DBD7CAEE">  parent_id,</span></span>
150<span class="line"><span style="color:#393A34;--shiki-dark:#DBD7CAEE">  ARRAY_AGG(id)::uuid[] </span><span style="color:#1E754F;--shiki-dark:#4D9375">AS</span><span style="color:#393A34;--shiki-dark:#DBD7CAEE"> child_ids</span></span>
151<span class="line"><span style="color:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="color:#393A34;--shiki-dark:#DBD7CAEE"> children</span></span>
152<span class="line"><span style="color:#1E754F;--shiki-dark:#4D9375">WHERE</span><span style="color:#393A34;--shiki-dark:#DBD7CAEE"> parent_id </span><span style="color:#AB5959;--shiki-dark:#CB7676">=</span><span style="color:#393A34;--shiki-dark:#DBD7CAEE"> ANY($</span><span style="color:#2F798A;--shiki-dark:#4C9A91">1</span><span style="color:#393A34;--shiki-dark:#DBD7CAEE">)</span></span>
153<span class="line"><span style="color:#1E754F;--shiki-dark:#4D9375">GROUP BY</span><span style="color:#393A34;--shiki-dark:#DBD7CAEE"> parent_id;</span></span></code></pre>
154<p>One row per parent, the database handles the grouping, and the type cast keeps the application honest about what it’s getting back. MySQL has <code>GROUP_CONCAT</code> with the same idea. The harder ones to spot are the in-memory groupings already sitting in y
154our codebase, doing it the slow way because that’s how it was written the first time.</p> </div> <footer class="mt-4 flex items-center justify-between gap-4 text-xs text-[color:var(--ink-muted)]"> <div class="min-w-0"> <ul class="tag-list flex flex-wrap gap-1.5 not-prose" data-astro-cid-lga65v7i><li data-astro-cid-lga65v7i><button type="button" data-search-tag="software" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag software" data-astro-cid-lga65v7i>
155#software</button></li></ul> </div> <a href="/notes/group-in-the-database/" class="shrink-0 no-underline text-[color:var(--ink-muted)] hover:text-[color:var(--accent)]" aria-label="Permalink to note from 19 August 2025"> <time datetime="2025-08-19T09:00:00.000Z">19 August 2025</time> <span class="ml-1.5" aria-hidden="true">#</span> </a> </footer> </article><article class="py-6 border-b border-[color:var(--border)] last:border-b-0">  <div class="prose prose-sm max-w-none"> <p>The <a href="https://github.com/graphql/dataloader" rel="noopener noreferrer" target="_blank"><code>dataloader</code></a> package ships with caching on. For the GraphQL resolver tree it was designed for that makes sense — stable reads within a single request are what you want, and the loader is garbage-collected with the request. Reuse a loader across requests or hold one for the lifetime of a server though and the cache becomes a <code>Map</code> that grows forever, never evicts, and quietly hands out stale reads.</p>
156<p>I default to <code>cache: false</code> everywhere and only turn it on when the underlying data is genuinely small, immutable, and unambiguously worth the trouble — a lookup table of statuses, a tiny set of feature flags. Anything that can change while the process is running, the cache is off.</p> </div> <footer class="mt-4 flex items-center justify-between gap-4 text-xs text-[color:var(--ink-muted)]"> <div class="min-w-0"> <ul class="tag-list flex flex-wrap gap-1.5 not-prose" data-collapsible data-astro-cid-lga65v7i><li data-astro-cid-lga65v7i><button type="button" data-search-tag="software" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag software" data-astro-cid-lga65v7i>
157#software</button></li><li class="tag-list__more" data-astro-cid-lga65v7i><button type="button" class="pill px-2 py-0.5 text-xs" aria-expanded="false" aria-label="Show 1 more tag" data-astro-cid-lga65v7i>
158+ 1 more
159</button></li><li class="tag-list__hidden" data-astro-cid-lga65v7i><button type="button" data-search-tag="javascript" class="pill px-2 py-0.5 text-xs" aria-label="Search for tag javascript" tabindex="-1" data-astro-cid-lga65v7i>
160#javascript</button></li></ul> </div> <a href="/notes/dataloader-cache-off-by-default/" class="shrink-0 no-underline text-[color:var(--ink-muted)] hover:text-[color:var(--accent)]" aria-label="Permalink to note from 6 August 2025"> <time datetime="2025-08-06T09:00:00.000Z">6 August 2025</time> <span class="ml-1.5" aria-hidden="true">#</span> </a> </footer> </article> </section>  </main> <footer class="border-t border-[color:var(--border)] mt-16 py-10 text-sm text-[color:var(--ink-muted)]"> <div class="container-wide flex flex-col sm:flex-row justify-between gap-4"> <p>&copy; 2026 Marty.</p> <p class="flex gap-4"> <a href="/rss.xml">RSS</a> </p> </div> </footer> <dialog id="search-dialog" class="w-[min(92vw,560px)] rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-0 shadow-2xl [margin:10vh_auto_auto] backdrop:bg-black/40 text-[color:var(--ink)]" aria-label="Search posts and notes" data-astro-cid-euff75kw> <div class="flex items-center gap-2 border-b border-[color:var(--border)] px-4" data-astro-cid-euff75kw> <svg class="w-4 h-4 flex-none text-[color:var(--ink-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-euff75kw> <circle cx="11" cy="11" r="7" data-astro-cid-euff75kw></circle> <path d="m20 20-3.5-3.5" data-astro-cid-euff75kw></path> </svg> <input id="search-input" type="text" autocomplete="off" spellcheck="false" placeholder="Search posts and notes…" aria-label="Search posts and notes" aria-controls="search-results" class="w-full bg-transparent py-3 text-[0.95rem] text-[color:var(--ink)] outline-none placeholder:text-[color:var(--ink-muted)]" data-astro-cid-euff75kw> <kbd class="hidden sm:inline-flex flex-none rounded border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-1.5 py-0.5 font-mono text-[0.7rem] text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>
161ESC
162</kbd> </div> <div id="search-tags" class="flex flex-wrap gap-1.5 border-b border-[color:var(--border)] px-3 py-2" role="group" aria-label="Filter by tag" data-astro-cid-euff75kw> <button type="button" data-tag="software" class="search-tag-btn inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-2 py-0.5 text-[0.7rem] text-[color:var(--ink-soft)] hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] transition-colors" data-astro-cid-euff75kw> <span data-astro-cid-euff75kw>#software</span> <span class="text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>
16219</span> </button><button type="button" data-tag="web" class="search-tag-btn inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-2 py-0.5 text-[0.7rem] text-[color:var(--ink-soft)] hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] transition-colors" data-astro-cid-euff75kw> <span data-astro-cid-euff75kw>#web</span> <span class="text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>12</span> </button><button type="button" data-tag="javascript" class="search-tag-btn inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-2 py-0.5 text-[0.7rem] text-[color:var(--ink-soft)] hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] transition-colors" data-astro-cid-euff75kw> <span data-astro-cid-euff75kw>#javascript</span> <span class="text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>8</span> </button><button type="button" data-tag="ai" class="search-tag-btn inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-2 py-0.5 text-[0.7rem] text-[color:var(--ink-soft)] hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] transition-colors" data-astro-cid-euff75kw> <span data-astro-cid-euff75kw>#ai</span> <span class="text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>7</span> </button><button type="button" data-tag="gaming" class="search-tag-btn inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-2 py-0.5 text-[0.7rem] text-[color:var(--ink-soft)] hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] transition-colors" data-astro-cid-euff75kw> <span data-astro-cid-euff75kw>#gaming</span> <span class="text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>6</span> </button><button type="button" data-tag="tools" class="search-tag-btn inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-2 py-0.5 text-[0.7rem] text-[color:var(--ink-soft)] hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] transition-colors" data-astro-cid-euff75kw> <span data-astro-cid-euff75kw>#tools</span> <span class="text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>6</span> </button><button type="button" data-tag="apis" class="search-tag-btn inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-2 py-0.5 text-[0.7rem] text-[color:var(--ink-soft)] hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] transition-colors" data-astro-cid-euff75kw> <span data-astro-cid-euff75kw>#apis</span> <span class="text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>5</span> </button><button type="button" data-tag="meta" class="search-tag-btn inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-2 py-0.5 text-[0.7rem] text-[color:var(--ink-soft)] hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] transition-colors" data-astro-cid-euff75kw> <span data-astro-cid-euff75kw>#meta</span> <span class="text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>5</span> </button><button type="button" data-tag="personal" class="search-tag-btn inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-2 py-0.5 text-[0.7rem] text-[color:var(--ink-soft)] hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] transition-colors" data-astro-cid-euff75kw> <span data-astro-cid-euff75kw>#personal</span> <span class="text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>4</span> </button><button type="button" data-tag="databases" class="search-tag-btn inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-2 py-0.5 text-[0.7rem] text-[color:var(--ink-soft)] hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] transition-colors" data-astro-cid-euff75kw> <span data-astro-cid-euff75kw>#databases</span> <span class="text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>
1623</span> </button><button type="button" data-tag="communication" class="search-tag-btn inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-2 py-0.5 text-[0.7rem] text-[color:var(--ink-soft)] hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] transition-colors" data-astro-cid-euff75kw> <span data-astro-cid-euff75kw>#communication</span> <span class="text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>2</span> </button><button type="button" data-tag="graphql" class="search-tag-btn inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-2 py-0.5 text-[0.7rem] text-[color:var(--ink-soft)] hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] transition-colors" data-astro-cid-euff75kw> <span data-astro-cid-euff75kw>#graphql</span> <span class="text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>2</span> </button><button type="button" data-tag="hardware" class="search-tag-btn inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-2 py-0.5 text-[0.7rem] text-[color:var(--ink-soft)] hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] transition-colors" data-astro-cid-euff75kw> <span data-astro-cid-euff75kw>#hardware</span> <span class="text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>2</span> </button><button type="button" data-tag="books" class="search-tag-btn inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-2 py-0.5 text-[0.7rem] text-[color:var(--ink-soft)] hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] transition-colors" data-astro-cid-euff75kw> <span data-astro-cid-euff75kw>#books</span> <span class="text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>1</span> </button><button type="button" data-tag="finance" class="search-tag-btn inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-2 py-0.5 text-[0.7rem] text-[color:var(--ink-soft)] hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] transition-colors" data-astro-cid-euff75kw> <span data-astro-cid-euff75kw>#finance</span> <span class="text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>1</span> </button><button type="button" data-tag="homestead" class="search-tag-btn inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-2 py-0.5 text-[0.7rem] text-[color:var(--ink-soft)] hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] transition-colors" data-astro-cid-euff75kw> <span data-astro-cid-euff75kw>#homestead</span> <span class="text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>1</span> </button><button type="button" data-tag="minimalism" class="search-tag-btn inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-2 py-0.5 text-[0.7rem] text-[color:var(--ink-soft)] hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] transition-colors" data-astro-cid-euff75kw> <span data-astro-cid-euff75kw>#minimalism</span> <span class="text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>1</span> </button><button type="button" data-tag="showerthought" class="search-tag-btn inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] bg-[color:var(--bg-soft)] px-2 py-0.5 text-[0.7rem] text-[color:var(--ink-soft)] hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] transition-colors" data-astro-cid-euff75kw> <span data-astro-cid-euff75kw>#showerthought</span> <span class="text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>1</span> </button> </div> <ul id="search-results" role="listbox" aria-label="Search results" class="m-0 max-h-[58vh] list-none overflow-y-auto p-2 not-prose" data-astro-cid-euff75kw></ul> <p id="search-empty" class="hidden px-4 py-8 text-center text-sm text-[color:var(--ink-muted)]" data-astro-cid-euff75kw>
163No matches.
164</p> </dialog>  
164<script type="application/json" id="search-data">[{"k":"post","t":"How I build Nest.js services","s":"Close to a decade and dozens of production services in: the project structure, dependency injection rules, test harness, consumers, migrations, auth and monitoring I start every Nest.js project with, ","u":"/posts/how-i-build-nestjs-services/","d":"21 Sept 2026","g":["software","javascript","graphql","databases","ai"]},{"k":"post","t":"Coding from my phone","s":"Terminus, Tailscale, and a NUC in the study. The setup I use to get real work done from a phone, and the two places it stops being pleasant.","u":"/posts/coding-from-my-phone/","d":"17 Aug 2026","g":["software","ai","tools","hardware"]},{"k":"post","t":"Ask for the method, not the fix","s":"Six months of confident, plausible, wrong fixes for the same Postgres incident — and what changed when I stopped asking Claude for a patch and asked it to teach me how to diagnose one.","u":"/posts/ask-for-the-method-not-the-fix/","d":"22 July 2026","g":["software","ai","databases","tools"]},{"k":"post","t":"Steam Machine, or the box you'd build for the money","s":"Valve's Steam Machine landed in Australia at $1,609. I priced a cheaper build, a like-for-like one, and a higher-end one against it — and thanks to the RAM crisis, the usual just-build-one answer isn'","u":"/posts/steam-machine-vs-a-build/","d":"3 July 2026","g":["hardware","gaming"]},{"k":"post","t":"Everything you own asks for something back","s":"Not the one-plant, two-t-shirts kind of minimalism. A slower version built around a single idea — cost of ownership — and what changed once it clicked: a simpler wardrobe, a quieter desk, a streamline","u":"/posts/everything-you-own-asks-for-something-back/","d":"26 June 2026","g":["personal","minimalism"]},{"k":"post","t":"Your values aren't the ones you'd list","s":"Most of what people call their values — being active, a big social circle, a good career — are really social idealisms: the life they think they should want. A short take on the distinction John DeMar","u":"/posts/social-idealisms-arent-values/","d":"21 June 2026","g":["personal","books"]},{"k":"post","t":"Raise your hand first","s":"Owning a mistake looks like it should cost you standing. Done consistently and meant, it does the opposite — here's the outage that taught me why, and the mechanism underneath it.","u":"/posts/raise-your-hand-first/","d":"19 June 2026","g":["personal","communication"]},{"k":"post","t":"The bill for being direct","s":"Most people who call themselves brutally honest just can't read the room. Being direct when you can read it — and saying the true thing anyway — is a different trade, and the bill comes due differentl","u":"/posts/the-bill-for-being-direct/","d":"12 June 2026","g":["personal","communication"]},{"k":"post","t":"Stable URLs without a redirect table","s":"A short random prefix plus a throwaway slug gives you URLs that survive title changes — no UUID ugliness, no slug-history table. Plus why the collision-retry loop matters more than the keyspace sugges","u":"/posts/stable-urls-without-a-redirect-table/","d":"1 June 2026","g":["software","web","apis"]},{"k":"post","t":"How this blog gets written","s":"How posts and notes here actually get made — AI-assisted, with a structured pass that flags speculation rather than laundering it as fact.","u":"/posts/how-this-blog-gets-written/","d":"29 May 2026","g":["meta","ai","tools"]},{"k":"post","t":"My 2026 Tech Stack","s":"Two years on from the 2024 list. The shipping-code part has changed completely; most of the rest looks the same — and where it doesn't, the reasons are worth knowing.","u":"/posts/my-2026-tech-stack/","d":"28 May 2026","g":["software","web","ai","meta","tools"]},{"k":"post","t":"The rent-vs-buy spreadsheet I finally closed","s":"For a couple of years I was sure that renting and tipping the difference into index funds beat buying. Then I bought a smaller, older house on a worse street — and worked out what the spreadsheet had ","u":"/posts/rent-vs-buy-spreadsheet/","d":"20 May 2026","g":["finance","homestead"]},{"k":"post","t":"Promise.all isn't batching","s":"When parallelising N small queries looks like a fix and is really just self-DoS at a slightly higher concurrency level.","u":"/posts/promise-all-isnt-batching/","d":"26 Aug 2025","g":["software","javascript","web","apis"]},{"k":"post","t":"Write batch-shaped functions by default","s":"Why I write repository methods that take arrays and return Maps, even when the only caller — today — is asking for one thing.","u":"/posts/write-batch-shaped-functions-by-default/","d":"12 Aug 2025","g":["software","javascript","apis"]},{"k":"post","t":"Batching requests we don't control","s":"How pushing dataloader up to the HTTP controller — out of the resolver layer it usually lives in — cut downstream database load by 83% on a read-heavy endpoint.","u":"/posts/batching-requests-we-dont-control/","d":"7 Mar 2025","g":["software","web","javascript","apis"]},{"k":"post","t":"My 2024 Tech Stack","s":"What I'm reaching for this year — languages, frameworks, infra — and why.","u":"/posts/my-2024-tech-stack/","d":"7 Mar 2024","g":["software","web","meta","tools"]},{"k":"post","t":"JavaScript Game Development Engine Ideas","s":"Where the JavaScript game-engine landscape has gaps, and how I'd fill them if I built one.","u":"/posts/javascript-game-development-engine-ideas/","d":"4 Mar 2024","g":["software","gam
164ing","javascript","web"]},{"k":"post","t":"Thoughts on GraphQL After 2 Years","s":"What two years of running a production GraphQL service taught me about where it earns its keep and where it doesn't.","u":"/posts/thoughts-on-graphql-after-2-years/","d":"21 Nov 2022","g":["software","graphql","web","apis"]},{"k":"note","t":"TIL that CREATE TEMPORARY TABLE in Postgres takes an ON COMMIT clause: Three settings. PRESERVE ROWS is the default and ","s":"TIL that CREATE TEMPORARY TABLE in Postgres takes an ON COMMIT clause: Three settings. PRESERVE ROWS is the default and keeps the table around for the whole session. DELETE ROWS truncates it at every commit. DROP bins the table itself at the end of the transaction that created it, which is almost always what I actually wanted — a scratch table for one bulk load or one gnarly multi step query, gone","u":"/notes/postgres-on-commit-drop/","d":"3 Sept 2026","g":["software","databases"]},{"k":"note","t":"Threw together Relink Codex — playstyles, combo routes, weapons and sigil loadouts for all 28 characters in Granblue Fan","s":"Threw together Relink Codex — playstyles, combo routes, weapons and sigil loadouts for all 28 characters in Granblue Fantasy: Relink . Two things collided. The expansion pulled me back into the game, I'm somewhere around Proud and trying to learn characters I've never touched, and the usual way to do that is trawling Reddit threads and pausing YouTube videos on the frame where the actual informati","u":"/notes/relink-codex/","d":"5 Aug 2026","g":["gaming","web","software","ai"]},{"k":"note","t":"Summer Game Fest dropped on Friday and somehow the lineup read like it was scraped straight from my wishlist. Three of m","s":"Summer Game Fest dropped on Friday and somehow the lineup read like it was scraped straight from my wishlist. Three of my favourite franchises, all in one show, all within a couple of hours of each other. I keep waiting for the catch. Code Veronica is finally getting the remake treatment — retitled just Resident Evil: Veronica , Claire back as the lead, the reveal opening on a rainy Paris apartmen","u":"/notes/summer-game-fest-2026-perfect-lineup/","d":"7 June 2026","g":["gaming"]},{"k":"note","t":"Spent the week deciding on a physics engine for the game engine and landed on Rapier. It's written in Rust and ships to ","s":"Spent the week deciding on a physics engine for the game engine and landed on Rapier. It's written in Rust and ships to the browser as WASM, which sounds like overkill for a 2D hobby engine until you watch the alternatives fall over. PhysicsJS was the first I crossed off — last meaningful release was years ago, and I'm not building on something nobody's touched since the previous decade. Matter.js","u":"/notes/rapier-for-the-game-engine/","d":"4 June 2026","g":["software","gaming","javascript","web"]},{"k":"note","t":"The comments on here are down right now. They run on Cusdis — open source, on their free hosted tier, and the most light","s":"The comments on here are down right now. They run on Cusdis — open source, on their free hosted tier, and the most lightweight option I could find. I've used a few commenting systems over the years — FastComments, Disqus, that lot — and Cusdis was the leanest by a distance: no tracker payload, no account wall, just a textbox. The catch with lightweight and free is that when the hosted instance fal","u":"/notes/comments-are-down-thats-the-deal/","d":"1 June 2026","g":["meta","software","web","tools"]},{"k":"note","t":"Two years after writing up some ideas for a JavaScript game engine, I've finally started building it. Early days, but it","s":"Two years after writing up some ideas for a JavaScript game engine, I've finally started building it. Early days, but it lives at arcade2d.com.","u":"/notes/arcade2d-finally-started/","d":"28 May 2026","g":["software","gaming","javascript","web"]},{"k":"note","t":"New site, new shape. The old one was a developer CV with blog. This one is broader — software, hardware, AI, the homeste","s":"New site, new shape. The old one was a developer CV with blog. This one is broader — software, hardware, AI, the homestead, gaming, the occasional showerthought. Short stuff lives here in Notes; longer pieces under Posts. Built with Astro and Tailwind on Cloudflare Workers. Most of the wiring — components, porting old articles, even the voice this note is written in — went through Claude Code. Wor","u":"/notes/new-site-new-shape/","d":"28 May 2026","g":["meta","ai","showerthought"]},{"k":"note","t":"The instinct when something's slow is to put Redis in front of it. Sometimes that's right. Often it's a way to defer thi","s":"The instinct when something's slow is to put Redis in front of it. Sometimes that's right. Often it's a way to defer thinking about the actual problem, with the added bonus of now having two: the original slowness plus a cache that drifts out of sync with the truth in ways you can't predict. The question that filters most \"let's add a cache\" proposals: how does it get invalidated? If the answer is","u":"/notes/caches-need-an-invalidation-story/","d":"29 Aug 2025","g":["software","web"]},{"k":"note","t":"A common shape: fetch a flat list of rows, then group them in application code into a Map<parentId, Row[] before returni","s":"A common shape: fetch a flat list of rows, then group them in application code into a Map<parentId, Row[] before returning. It works, it's familiar, and it's nearly always slower and more memory hungry than asking the database to do it. One row per parent, the database handles the grouping, and the type cast keeps the application honest about what it's getting back. MySQL has GROUP CONCAT with the","u":"/notes/group-in-the-database/","d":"19 Aug 2025","g":["software"]},{"k":"note","t":"The dataloader package ships with caching on. For the GraphQL resolver tree it was designed for that makes sense — stabl","s":"The dataloader package ships with caching on. For the GraphQL resolver tree it was designed for that makes sense — stable reads within a single request are what you want, and the loader is garbage collected with the request. Reuse a loader across requests or hold one for the lifetime of a server though and the cache becomes a Map that grows forever, never evicts, and quietly hands out stale reads.","u":"/notes/dataloader-cache-off-by-default/","d":"6 Aug 2025","g":["software","javascript"]}]</script>
164 
164<script type="module">const l=document.getElementById("search-dialog"),o=document.getElementById("search-input"),v=document.getElementById("search-results"),k=document.getElementById("search-empty"),w=document.getElementById("search-data"),C=document.getElementById("search-tags");if(l&&o&&v&&k&&w){const L=JSON.parse(w.textContent??"[]");let c=[],i=0;const r=new Set,g=()=>{C?.querySelectorAll(".search-tag-btn").forEach(e=>{const t=e.dataset.tag??"";e.setAttribute("aria-pressed",String(r.has(t)))})},x=e=>{r.has(e)?r.delete(e):r.add(e),g()},b=e=>{r.clear();for(const t of e)r.add(t);g()},I=(e,t)=>{const s=e.t.toLowerCase();if(s===t)return 0;if(s.startsWith(t))return 1;const n=s.indexOf(t);return n>=0?2+n/1e3:e.g.join(" ").includes(t)?30:e.s.toLowerCase().includes(t)?50:1/0},A=e=>{const t=e.trim().toLowerCase();let s=L;return r.size>0&&(s=s.filter(n=>[...r].every(a=>n.g.includes(a)))),t?s.map(n=>({it:n,s:I(n,t)})).filter(n=>n.s!==1/0).sort((n,a)=>n.s-a.s||n.it.t.localeCompare(a.it.t)).map(n=>n.it):s.slice(0,20)},B=(e,t,s)=>{const n=document.createElement("li");n.setAttribute("role","option");const a=document.createElement("a");a.href=e.u,a.dataset.index=String(s),a.className="flex items-center gap-3 rounded-md px-3 py-2 no-underline hover:no-underline";const E=document.createElement("span");E.className="flex-none rounded border border-[color:var(--border)] px-1.5 py-0.5 text-[0.6rem] font-semibold uppercase tracking-[0.06em] text-[color:var(--ink-muted)]",E.textContent=e.k;const d=document.createElement("span");d.className="flex-1 truncate text-[color:var(--ink)]";const D=e.t.toLowerCase(),u=t?D.indexOf(t):-1;if(u>=0&&t){d.append(e.t.slice(0,u));const y=document.createElement("span");y.className="underline decoration-2 underline-offset-2 text-[color:var(--accent)]",y.textContent=e.t.slice(u,u+t.length),d.append(y,e.t.slice(u+t.length))}else d.textContent=e.t;const h=document.createElement("span");return h.className="ml-auto flex-none text-xs text-[color:var(--ink-muted)]",h.textContent=e.d,a.append(E,d,h),n.append(a),n},m=e=>{c.length!==0&&(i=(e+c.length)%c.length,v.querySelectorAll("a[data-index]").forEach((t,s)=>{const n=s===i;t.classList.toggle("bg-[color:var(--accent-soft)]",n),t.setAttribute("aria-selected",String(n)),n&&t.scrollIntoView({block:"nearest"})}))},S=e=>{v.replaceChildren(...c.map((t,s)=>B(t,e,s))),k.classList.toggle("hidden",c.length>0),i=0,m(0)},f=()=>{c=A(o.value),S(o.value.trim().toLowerCase())},p=(e=null)=>{const t=l.open;t?e&&x(e):(o.value="",b(e?[e]:[])),f(),t?o.focus():(l.showModal(),requestAnimationFrame(()=>o.focus()))};o.addEventListener("input",f),o.addEventListener("keydown",e=>{if(e.key==="ArrowDown")e.preventDefault(),m(i+1);else if(e.key==="ArrowUp")e.preventDefault(),m(i-1);else if(e.key==="Enter"){e.preventDefault();const t=c[i];t&&window.location.assign(t.u)}else if(e.key==="Backspace"&&!o.value&&r.size>0){e.preventDefault();const t=[...r].pop();r.delete(t),g(),f()}}),C?.addEventListener("click",e=>{const t=e.target.closest(".search-tag-btn");t?.dataset.tag&&(x(t.dataset.tag),f(),o.focus())}),l.addEventListener("click",e=>{e.target===l&&l.close()});const T=e=>{if(!(e instanceof HTMLElement))return!1;if(e.isContentEditable)return!0;const t=e.tagName;return t==="INPUT"||t==="TEXTAREA"||t==="SELECT"};document.addEventListener("keydown",e=>{if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==="k"){e.preventDefault(),p();return}e.key==="/"&&!e.metaKey&&!e.ctrlKey&&!e.altKey&&!l.open&&!T(e.target)&&(e.preventDefault(),p())}),document.addEventListener("click",e=>{const t=e.target.closest("[data-search-open], [data-search-tag]");if(!t)return;e.preventDefault();const s=t.dataset.searchTag?.toLowerCase()??null;p(s)})}</script>
164 
164<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/v31edd6df95cf4e85bb4c19e7a9bdbcba1788362987495" integrity="sha512-iIg7k2xntmwu6/uSb5tpc/hySgZc4eoL31yB29W6tJFo2akwjPWcEqnCEdJvGexCL0KEQwVYv5BlowfhVz26hg==" data-cf-beacon='{"version":"2024.11.0","token":"5f2b6111917a431ea563c568bb099c6f","r":1,"spa":2}' crossorigin="anonymous"></script>
164
165</body></html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.