---
title: "The admin write contract"
slug: the-admin-write-contract
section: tech
date: 2026-06-19T20:14:00.000Z
canonical: https://roland.leth.ro/blog/tech/the-admin-write-contract
---

Every admin write on the site does three things, in order: it mutates the database, it writes a structured audit line, and (on the UI side) it commits an optimistic state update before the network resolves. Each of those was its own copy-paste mess for a while: a handful of handlers each doing roughly the same thing, with subtle drift on the parts that mattered. This post is about the small toolkit they collapsed into.

This is a layer further inside than [the validation post][prev]. By the time we get here, the body has been parsed, the user is authenticated, and we have typed data in hand. The interesting work is what the handler does _with_ it.

## The audit log

Failed admin writes log inside the handler (the `try/catch` around the write does that for you). Successful ones don't, unless you make them. Without an explicit audit line, the access log can't answer "did someone create a post at 3am"; the 200 looks like every other 200.

So every successful admin handler ends with a call to `auditLog`:

```typescript
// src/lib/auditLog.ts
export const ADMIN_AUDIT_TAGS = [                              // ❶
  "[api:admin:posts:POST]",
  "[api:admin:posts:PUT]",
  "[api:admin:posts:DELETE]",
  "[api:admin:posts:BULK]",
  "[api:admin:projects:POST]",
  "[api:admin:projects:PUT]",
  "[api:admin:projects:DELETE]",
] as const

export type AdminAuditTag = (typeof ADMIN_AUDIT_TAGS)[number]  // ❷

export interface AdminAuditPayload {
  id: number
  slug: string | null
  section: Section | null
  sortOrder: number | null
  previousSection: Section | null
  previousSlug: string | null
}

export function auditLog(tag: AdminAuditTag, payload: AdminAuditPayload): void {
  console.info(`${tag} success`, payload)
}
```

The output is one `console.info` line per successful write, prefixed with the route tag and carrying a typed payload. Grep-able by tag, id, slug, or section.

Two design choices keep this from drifting. First, the tag list is a `const` array ❶: adding a new admin resource is one edit, and the runtime list of valid tags is greppable from the array itself, not scattered across call sites. The type derived from it ❷ is what makes the discipline stick: `auditLog("[api:admin:psots:POST]", …)` fails at `tsc`, so a typo'd tag can't quietly emit a malformed line and break every log-aggregator grep set up against the real tag.

Second, the payload's fields are all `T | null`, never `T | undefined` or optional `?`. At a call site:

```typescript
auditLog("[api:admin:posts:POST]", {
  id: post.id,
  slug: post.slug,
  section: post.section,
  sortOrder: null,
  previousSection: null,
  previousSlug: null,
})
```

The verbosity is the feature. A handler that fails to populate a field has to think about it and pass `null` explicitly. Leaving it optional meant "I forgot" and "this field doesn't apply" produced the same output, which is exactly the case where silent regressions hide. A future me adding `tags` to the `Post` model has to pick: "is the tag list interesting enough to audit?" The `T | null` discipline forces that question instead of letting it slide.

The one payload field that doesn't fall out of the type is `previousSlug`. Renames are the one mutation that breaks log-grep: if `hello-world` becomes `the-shape-of-hello-world`, a future search for the old name finds nothing because `slug` in the audit line is now the new value. `previousSlug` closes that gap. On a PUT that changes the slug, the audit line carries both names; on every other mutation, `previousSlug` is `null`.

The two handlers that emit `previousSlug` (posts PUT and projects PUT) compute it the same way: re-read the row inside the transaction, snapshot before the update. Without that unification, post-renames and project-renames would have logged different shapes and the aggregator couldn't grep "what was the previous slug of post X" cleanly.

## Optimistic mutations, with all the small horrors

Two admin widgets save optimistically: the "Featured" toggle on each post (a checkbox that pins or unpins it from the top of the section) and the sort-order input on each project (a number that decides display position on the projects page). Both live inline in the list view, not behind an edit page. The UI flips immediately when you click; the network catches up. If the request fails, the UI reverts. If a newer mutate starts mid-flight, the older one becomes irrelevant; its result should be quietly discarded rather than overwriting the newer commit.

I extracted this into a hook at the second consumer, when the second copy of the boilerplate showed up:

```typescript
// src/lib/useOptimisticMutation.ts
export function useOptimisticMutation<TPayload>({
  url,
  method = "PUT",
}: Config) {
  const abortRef = useRef<AbortController | null>(null)
  const [isSaving, setIsSaving] = useState(false)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    return () => {
      abortRef.current?.abort()
      abortRef.current = null                                  // ❶
    }
  }, [])

  async function mutate(payload, { onRevert, errorFallback = "Failed to save" }) {
    // ...
  }

  return { mutate, isSaving, error }
}
```

The API is `mutate(payload, { onRevert })` plus the `isSaving` and `error` state for the UI. Internally, each `mutate` creates an `AbortController` (the standard API for cancelling an in-flight `fetch`), passes its `signal` into the request, and calls `abort()` on the previous controller if one is still in flight. The aborted `fetch` rejects with an `AbortError`, which the catch block recognizes and silently drops. That's how supersession works.

The non-obvious bit is ❶: the unmount cleanup nulls the ref _as well as_ aborting. That's not symmetry-for-its-own-sake; it's a real bug fix. In the narrow window where `fetch` resolves before the abort signal is observed — short buffered bodies, or a test mock that ignores signals — the non-ok branch's "is this controller still the latest?" guard would otherwise read the old controller and try to `setState` on an unmounted component. Nulling the ref makes the guard correct for every post-unmount continuation, not just the abort-rejection path.

I didn't think of that one. It came out of a test I'd written assuming the implementation was already right:

```typescript
it("does not call setState after unmount", async () => { /* … */ })
```

The test passed once, failed once on a flake, and the flake turned out to be the race above. The lesson I want to remember: a test premise that "should pass with the current implementation" sometimes uncovers an actual implementation gap, not a bad test.

## The return-type discriminant

`mutate` returns more than a success/failure boolean. Failure has two flavors that the caller treats differently: a real failure (the server returned non-ok or `fetch` threw) and a supersession (a newer call started, or the component unmounted mid-request). A discriminated union splits them:

```typescript
export type MutateResult =
  | { ok: true }
  | { ok: false; reason: "failure" | "superseded" }
```

`"failure"` means the server returned non-ok or `fetch` threw. The hook has already called `onRevert` and set the error message. The caller can show a toast, log, refresh.

`"superseded"` means a newer mutate started before this one resolved, or the component unmounted mid-request. The hook deliberately does _not_ call `onRevert` and does _not_ set the error; the newer call owns the outcome. The caller's job is to do nothing: no toast, no `router.refresh()`, no state churn.

Today's two callers (`IsFeaturedToggle` and `ProjectSortOrderInput`) collapse the two reasons together: both treat any `!ok` as "stop." The discriminant lands now so a future consumer that wants to skip a post-mutate `router.refresh()` specifically on supersession doesn't pay the extraction tax twice.

At the call site, here's `IsFeaturedToggle`'s change handler:

```typescript
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
  const initialIsFeatured = isFeatured
  const next = e.target.checked
  setIsFeatured(next)                                         // ❶

  const { ok } = await mutate(
    { isFeatured: next },
    { onRevert: () => setIsFeatured(initialIsFeatured) }       // ❷
  )

  if (ok) {
    router.refresh()                                          // ❸
  }
}
```

Three lines that map to the contract: commit the optimistic value ❶, describe how to revert ❷, do success-path side effects only when `ok` is true ❸. About thirty lines of plumbing per widget collapsed to this.

## isAbortError, the unifier

Inside `mutate`'s catch block, the two failure flavors get routed by `isAbortError`:

```typescript
} catch (err) {
  if (isAbortError(err)) {
    return { ok: false, reason: "superseded" }
  }
  onRevert()
  setError(errorFallback)
  return { ok: false, reason: "failure" }
}
```

An abort means a newer call started and rejected this fetch: silently drop, return `"superseded"`. Anything else (network down, server error, malformed response) is a real failure: revert, set the error, return `"failure"`.

The helper itself:

```typescript
export function isAbortError(err: unknown): boolean {
  if (err instanceof DOMException && err.name === "AbortError") {
    return true
  }

  if (err instanceof Error && err.name === "AbortError") {
    return true
  }

  return false
}
```

Two branches because different environments surface aborts differently: browsers throw a `DOMException`; happy-dom in the test runner throws a plain `Error` with `name === "AbortError"`.

Centralizing this matters for two reasons. Five admin call sites used to spell it out by hand with subtly different shapes — one was checking `err.code`, one was duck-typing `.aborted` — and the test-env behavior had diverged from production on at least one site without anyone noticing. And a future runtime switch (Bun's `fetch`, Node's native `fetch`, an HTTP client change) cannot silently turn an abort that should be swallowed into one that surfaces as an error toast.

## What's next

[The next post][next] is about the content side of the app: 200 markdown posts that need to render fast, drafts that need to stay invisible, and one feature ("schedule this post to publish in three days") that cascades into more places than you'd expect, including a `findUnique → findFirst` migration I didn't see coming.

If your optimistic-mutation hook turned out cleaner than mine, I'd genuinely like to read it: [@roland.leth.ro][].

[prev]: /blog/tech/input-validation-and-logs-that-dont-leak "Input validation and logs that don't leak"
[next]: /blog/tech/200-markdown-posts-and-the-future-dated-content-puzzle "200 markdown posts and the future-dated content puzzle"
[@roland.leth.ro]: https://bsky.app/profile/roland.leth.ro "@roland.leth.ro on Bluesky"
