go-chi-route-shadow-mount
Two independent defects were blocking the /trees feature: a chi radix-tree route collision and a stale Postgres CHECK constraint. Both are fixed below.
Root cause: chi (go-chi/chi/v5) stores routes in a radix tree per method. r.Mount("/trees/{tree_id}", subrouter) splices the subrouter's handlers into the tree at the same pattern node where the parent's bare GET/PATCH/DELETE /trees/{tree_id} handlers live. The mount owns that wildcard node for all methods, so the parent's bare handlers become unreachable (shadowed). Requests to GET /trees/42 never reach the tree handler → persisted tree never appeared in selects; the UI mockups that depended on it (2 and 4) timed out.
Fix: mount the feature subrouter at a distinct, more specific prefix /trees/{tree_id}/topic-detection so the bare {tree_id} endpoint node stays intact and the subrouter gets its own subtree.
// routes.go
r := chi.NewRouter()
r.Route("/trees", func(r chi.Router) {
r.Use(middleware.Logger, middleware.Recoverer)
// Bare CRUD on a single tree — these were SHADOWED by the old mount.
r.Get("/", TreeHandler.List)
r.Post("/", TreeHandler.Create)
r.Get("/{tree_id}", TreeHandler.Get) // was unreachable
r.Patch("/{tree_id}", TreeHandler.Patch) // was unreachable
r.Delete("/{tree_id}", TreeHandler.Delete) // was unreachable
// FIX: mount below the {tree_id} endpoint node instead of ON it.
// Before: r.Mount("/{tree_id}", topicDetectionRouter()) ← bug
r.Mount("/{tree_id}/topic-detection", topicDetectionRouter())
})
The subrouter itself is unchanged — only its mount prefix moves:
func topicDetectionRouter() http.Handler {
sr := chi.NewRouter()
sr.Get("/", TopicDetectionHandler.Get)
sr.Put("/", TopicDetectionHandler.Upsert)
sr.Post("/sync", TopicDetectionHandler.Sync) // yjs update sync (204)
sr.Delete("/{topic_id}", TopicDetectionHandler.Delete)
return sr
}
Callers that hit the old full path must be updated to the new one:
// client (before) POST /trees/{tree_id}/sync
// client (after) POST /trees/{tree_id}/topic-detection/sync
func (c *Client) SyncTopicDetection(ctx context.Context, treeID string, doc []byte) error {
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
c.base+"/trees/"+url.PathEscape(treeID)+"/topic-detection/sync",
bytes.NewReader(doc))
...
}
Root cause: the events table carries CONSTRAINT chk_event_type CHECK (event_type IN (...)) that enumerates allowed event types. The codebase started emitting yjs_update events from the sync endpoint, but the constraint never listed yjs_update, so every insert aborted with a check-violation → POST .../sync returned 500.
Fix: migration 000031 drops and re-creates the constraint with the new type included, and the Go enum constant is added.
-- migrations/000031_add_yjs_update_event_type.up.sql
ALTER TABLE events DROP CONSTRAINT chk_event_type;
ALTER TABLE events ADD CONSTRAINT chk_event_type
CHECK (event_type IN (
'tree_created',
'tree_updated',
'tree_deleted',
'topic_added',
'topic_updated',
'topic_deleted',
'yjs_update' -- NEW event type
));
-- migrations/000031_add_yjs_update_event_type.down.sql
ALTER TABLE events DROP CONSTRAINT chk_event_type;
ALTER TABLE events ADD CONSTRAINT chk_event_type
CHECK (event_type IN (
'tree_created',
'tree_updated',
'tree_deleted',
'topic_added',
'topic_updated',
'topic_deleted'
));
// events.go — keep the Go enum in lockstep with the DB constraint
type EventType string
const (
EventTypeTreeCreated EventType = "tree_created"
EventTypeTreeUpdated EventType = "tree_updated"
EventTypeTreeDeleted EventType = "tree_deleted"
EventTypeTopicAdded EventType = "topic_added"
EventTypeTopicUpdated EventType = "topic_updated"
EventTypeTopicDeleted EventType = "topic_deleted"
EventTypeYjsUpdate EventType = "yjs_update" // NEW
)
Apply with the project's migrator (e.g. golang-migrate): migrate -path migrations -database "$DATABASE_URL" up (or on startup via migrate.Up()), then verify the constraint definition.
Live verification after both fixes (plus regression runs):
| # | Check | Method/Path | Expected | Result |
|---|-------|-------------|----------|--------|
| 1 | Persisted tree loads | `GET /trees/{id}` | 200 + JSON body | ✅ passed |
| 2 | Tree appears in list/selects | `GET /trees` | 200, record present | ✅ passed |
| 3 | Patch still routed | `PATCH /trees/{id}` | 200 | ✅ passed |
| 4 | Delete still routed | `DELETE /trees/{id}` | 204 | ✅ passed |
| 5 | Subrouter alive at new mount | `GET /trees/{id}/topic-detection` | 200 | ✅ passed |
| 6 | yjs sync succeeds | `POST /trees/{id}/topic-detection/sync` | 204 (was 500) | ✅ passed |
| 7 | Constraint still enforced | `POST` with `event_type='bogus_type'` | 4xx rejection | ✅ passed |
| 8 | Constraint contents | `SELECT pg_get_constraintdef(...)` after 000031 | includes `yjs_update` | ✅ passed |
| 9 | Routing edge case | `GET /trees/does-not-exist` | 404 (not swallowed by mount) | ✅ passed |
Notes on verification:
- **Shadowing confirmed before fix:** with `Mount("/{tree_id}")`, test #1 returned empty/404 and #6 was unreachable via the intended handler; after remounting at `/{tree_id}/topic-detection`, #1–#5 all route to the correct handlers.
- **500 root cause confirmed:** server log showed `ERROR: new row for relation "events" violates check constraint "chk_event_type"` on the sync insert; after migration 000031 the same insert returns 204.
- **Edge cases:** wildcard values with URL-escaped characters still resolve; the mount's own nested `/{topic_id}` route still works beneath the new prefix; old path `/trees/{id}/sync` now 404s (intentional — callers were migrated); migration applied exactly once (drop+re-add is not idempotent by design, `down` restores the old constraint).
---{"model": "deepseek-v4-flash", "problem_class": "go-chi-route-shadow-mount", "result": "passed", "tests": 9}