Skip to content

feat(api): slug-history 301 redirect handler (closes #80)#92

Merged
themightychris merged 5 commits into
mainfrom
feat/slug-history-redirect
May 28, 2026
Merged

feat(api): slug-history 301 redirect handler (closes #80)#92
themightychris merged 5 commits into
mainfrom
feat/slug-history-redirect

Conversation

@themightychris

Copy link
Copy Markdown
Member

Summary

`specs/behaviors/slug-handles.md` → "Mutability and redirects" specifies a 301 redirect for any URL using a non-expired `oldSlug`. The write side already creates SlugHistory records on every rename (`account-claim.ts`, `project.write.ts`, `person.write.ts`); the read side did nothing with them. This PR fills that gap.

How it works

  1. In-memory state: new `slugHistory: Map<\`${entityType}:${oldSlug}\`, { newSlug, expiresAt }>` on `InMemoryState`. Boot loader populates from the slug-history sheet, filtering past-`expiresAt` records (read-path defense; the periodic sweeper that purges the sheet is a follow-up).
  2. StateApply: new `upsertSlugHistory(record)` op. Each of the three write sites that already write to the sheet now also queues the in-memory mutation so the redirect handler sees renames on the very next request.
  3. `slug-redirect` Fastify plugin: `onRequest` hook pattern-matches the four slug-bearing SPA paths from `apps/web/src/App.tsx` — `/projects/:slug`, `/members/:slug`, `/tags/:namespace/:slug`, `/projects/:slug/buzz/:buzzSlug` — and their sub-routes. For live-missing slugs with a non-expired history entry, follows the chain (multi-hop, capped at MAX_HOPS=8) and replies 301 with the rebuilt path. Live entities win over slug-history (handles "someone took the freed slug" from the spec).

Plugin order: `services` → `slug-redirect` (new) → `static-web` (existing SPA fallthrough notFoundHandler). `/api/*` paths bypass the hook entirely so route handlers own their own 404 envelopes.

Test plan

  • 11 new test cases in `apps/api/tests/slug-redirect.test.ts` (single-hop person + project, sub-route preservation, query-string preservation, multi-hop A→B→C, live wins, expired entry, reserved-segment passthrough, tag rename, /api/* bypass, key-format determinism). All pass.
  • All 255 API tests pass (244 pre-existing + 11 new); `npm run type-check && npm run lint` clean.
  • Sandbox smoke (deferred to deploy) — rename a project via the UI, visit the old URL, expect 301 to the new path with sub-route + query intact.

Closes #80.

🤖 Generated with Claude Code

themightychris and others added 5 commits May 28, 2026 08:51
The write side already creates SlugHistory records on every rename; the
read side is missing. This plan loads slug-history into the typed
in-memory Store (the path #47 documented), wires StateApply for
lockstep updates from the three write services that create the
records, and adds a Fastify onRequest hook that pattern-matches the
slug-bearing SPA paths and serves 301s for non-expired entries.

Includes the spec's edge cases: live-wins, multi-hop chain follow,
sub-route preservation, 90-day TTL filter.

Closes #80.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds slugHistory: Map<\`\${entityType}:\${oldSlug}\`, { newSlug,
expiresAt }> to InMemoryState. Boot loader populates from the
slug-history sheet, filtering out records past their 90-day TTL.
StateApply.upsertSlugHistory mirrors gitsheets upserts into the map
so the redirect handler sees renames immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three write sites currently call tx.public['slug-history'].upsert(history)
to persist the rename; now each also queues the corresponding in-memory
mutation so the slug-redirect plugin sees it on the very next request:

- person.write.ts:128 — profile-edit with new slug
- project.write.ts:305 — project rename
- account-claim.ts — post-onboarding merge, threaded through
  MergeApplyInput + MergeApply.replay so the route-level StateApply
  receives the slug-history op alongside person/membership/etc. rewrites

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New onRequest hook serves 301s for non-/api/* GETs whose slug component
matches a non-expired SlugHistory entry. Pattern-matches the four
slug-bearing SPA paths from apps/web/src/App.tsx:

  /projects/<slug>[/...]
  /members/<slug>[/...]
  /tags/<namespace>/<slug>[/...]
  /projects/<slug>/buzz/<buzzSlug>[/...]

Spec edges from behaviors/slug-handles.md covered:

  - Live wins: a slug that's a live entity of the same type never
    redirects (handles "someone took the freed slug").
  - Multi-hop A→B→C: chain follow with MAX_HOPS=8 short-circuit; the
    response is one 301 to the final live slug.
  - Sub-route + query preservation: /projects/old/edit?tab=x →
    /projects/new/edit?tab=x.
  - Expired entries are filtered at boot-time index, so a past-expiresAt
    SlugHistory record naturally misses the lookup.
  - /api/* paths bypass the hook entirely so API routes own their
    own 404 envelopes.

Cache-Control is deliberately short (max-age=300) — the 301 itself can
expire when the 90-day SlugHistory window does, so we avoid baking the
redirect into long browser caches.

Registered after `services` (which decorates inMemoryState) and before
`static-web` (which owns the SPA notFoundHandler fallthrough).

Tests cover 11 cases incl. live-wins, multi-hop chain, expired entry,
reserved-segment passthrough (/projects/create), and the API-path
carve-out. 255/255 API tests pass.

Closes #80.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closeout: flip to done, tick all validations (11 new redirect tests +
255/255 API tests, lint + type-check clean), fill Notes (MergeApply
threading, buzz live-index undelivery, tag handle-scan, deliberate
5-min Cache-Control) + Follow-ups (periodic sweeper, buzz live-index
when writers land, CDN edge-cache concerns).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@themightychris themightychris merged commit c9c8ccb into main May 28, 2026
1 check passed
@themightychris themightychris deleted the feat/slug-history-redirect branch May 28, 2026 13:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

api: SlugHistory 90-day redirect handler

1 participant