fix(api): rebuild every in-memory index on hot reload - #159
Merged
Conversation
themightychris
added a commit
that referenced
this pull request
Sep 8, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
The hot-reload section described the rebuild as "mutate the live Maps in place" without saying which Maps. The implementation had quietly skipped three secondary indices (legacy-id, buzz-by-slug, slug-history), which is exactly the gap an unqualified sentence leaves open. State the invariant explicitly: every collection on the live state, primary and secondary, is replaced from the fresh one, so no lookup path can serve pre-reload contents after a reload. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
swapInPlace named each Map on InMemoryState by hand and skipped three secondary indices: projectIdByLegacyId, buzzIdBySlug, and slugHistory. After POST /api/_internal/reload-data those three still described the pre-reload state. Because the laddr importer mints fresh UUIDv7 ids on every run, a re-import merged into `published` followed by a hot reload left projectIdByLegacyId pointing at project ids that no longer existed, so legacy /projects?ID=<n> redirects fell through to the SPA until the pod restarted; /project-buzz/<slug> and slug-history 301s went stale the same way. Enumerate the fresh state's own properties instead of maintaining a list, and throw if a property is ever not a Map so a future field is handled deliberately rather than skipped again. Guards against recurrence: - reload-swap.test.ts builds two states with one record of every entity type (different ids, same legacy ids), swaps, and asserts every own property of the fresh state was replaced while Map identities are kept. Fails on the old code for all three missing indices. - internal-reload.test.ts gains a re-import scenario through the real webhook: the project and buzz are replaced with fresh ids and slugs plus a slug-history record, and the legacy-id, buzz-slug, and old-slug redirects must all land on the new slug afterwards. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
themightychris
force-pushed
the
fix/hot-reload-stale-indices
branch
from
September 9, 2026 00:12
825c2e1 to
d7c5f9f
Compare
Merged
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
swapInPlace(apps/api/src/store/memory/reload.ts) named each Map onInMemoryStateby hand and skipped three secondary indices:projectIdByLegacyId,buzzIdBySlug, andslugHistory. AfterPOST /api/_internal/reload-datathose three still described the pre-reload state.Because the laddr importer mints fresh UUIDv7 ids on every run, a re-import merged into
publishedfollowed by a hot reload leftprojectIdByLegacyIdpointing at project ids that no longer existed. Legacy/projects?ID=<n>and/project-updates?ProjectID=<n>redirects fell through to the SPA (404) until the pod restarted;/project-buzz/<slug>and slug-history 301s went stale the same way.Changes
specs/behaviors/storage.md→ Hot reload → Atomicity): states that every collection on the live state, primary maps and every secondary index including legacy-id, buzz-by-slug, and slug-history, is replaced from the fresh state.swapInPlaceenumerates the fresh state's own properties instead of maintaining a list, and throws if a property is ever not a Map so a future field gets handled deliberately instead of skipped.apps/api/tests/reload-swap.test.ts): builds two states with one record of every entity type (different ids, same legacy ids), swaps, and asserts every own property was replaced while Map identities are preserved. Fails on the old code for all three missing indices.apps/api/tests/internal-reload.test.ts): re-import scenario through the real webhook. Project and buzz are replaced with fresh ids and slugs plus a slug-history record; the legacy-id, buzz-slug, and old-slug redirects must all land on the new slug afterwards. Fails on the old code.plans/hot-reload-stale-indices.md.Validation
npm run type-checkcleannpm run lintcleannpm test: api 34 files / 428 tests, web 23 / 89, shared 3 / 75 — all greenNote
While writing the integration test I found that boot-time reconcile does not re-open the gitsheets store snapshot the way the webhook path does, so if the local clone is behind at boot the in-memory state is built from the pre-fast-forward tree. Production pods bare-clone fresh on every boot so this doesn't bite there; filed as a follow-up issue, out of scope here.
🤖 Generated with Claude Code
https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr