feat(api): slug-history 301 redirect handler (closes #80)#92
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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
Closes #80.
🤖 Generated with Claude Code