feat: add absorb command — merge source entity into target

DB: Absorb() merges body (newline-separated), unions tags, demotes
crystallized sources, soft-deletes source. Rejects crystallized targets.

API: POST /api/entities/:id/absorb { source_id }

CLI: nib absorb <target> <source> with prefix ID resolution

Web: absorb button on fluid entities, 'a' keyboard shortcut,
source picker modal
This commit is contained in:
2026-05-14 13:47:08 -04:00
parent 702caae1af
commit 7711240d68
9 changed files with 341 additions and 9 deletions
+44
View File
@@ -304,6 +304,50 @@ func demoteEntity(store *db.Store) http.HandlerFunc {
}
}
type AbsorbRequest struct {
SourceID string `json:"source_id"`
}
func absorbEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req AbsorbRequest
if !decodeJSON(w, r, &req) {
return
}
if req.SourceID == "" {
writeError(w, http.StatusBadRequest, "invalid_input", "source_id is required")
return
}
if req.SourceID == id {
writeError(w, http.StatusBadRequest, "invalid_input", "target and source must be different entities")
return
}
if err := store.Absorb(id, req.SourceID); err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "target or source entity not found")
return
}
if err == db.ErrTargetCrystallized {
writeError(w, http.StatusBadRequest, "invalid_absorb", "target is crystallized — demote first")
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
e, err := store.Get(id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
}
}
func useEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")