feat(14-01): beachcomber DropIndex and EnsureIndex for reindex tooling

- optional IndexDropper reports whether a dropped index existed; DropIndex falls back to Flush
- optional IndexEnsurer creates an empty index from its schema; EnsureIndex is a no-op otherwise
- typesense implements both (404 is already absent; ensure reuses collection creation)
This commit is contained in:
Jakub Zych
2026-10-03 20:01:36 +02:00
parent 7241704e93
commit 58e6324da8
6 changed files with 214 additions and 1 deletions

View File

@@ -47,6 +47,10 @@ type Engine struct {
}
var _ beachcomber.Engine = (*Engine)(nil)
var (
_ beachcomber.IndexDropper = (*Engine)(nil)
_ beachcomber.IndexEnsurer = (*Engine)(nil)
)
// New returns an engine for cfg. Every request is bounded by
// cfg.ConnectionTimeout.
@@ -214,6 +218,33 @@ func (e *Engine) Flush(ctx context.Context, index string) error {
return nil
}
// DropIndex drops the collection with DELETE /collections/{index} and
// reports whether it existed: 2xx is true, 404 is false, any other status is
// a StatusError. It implements beachcomber.IndexDropper.
func (e *Engine) DropIndex(ctx context.Context, index string) (bool, error) {
path := "/collections/" + url.PathEscape(index)
code, _, err := e.do(ctx, http.MethodDelete, path, nil, nil, "")
if err != nil {
return false, err
}
switch {
case ok2xx(code):
return true, nil
case code == http.StatusNotFound:
return false, nil
default:
return false, statusError(http.MethodDelete, path, code)
}
}
// EnsureIndex creates the collection from schema when Typesense does not
// have it (GET /collections/{index}, then POST /collections on 404, 409 as
// success), the same step Upsert runs. It implements
// beachcomber.IndexEnsurer.
func (e *Engine) EnsureIndex(ctx context.Context, index string, schema map[string]any) error {
return e.ensureCollection(ctx, index, schema)
}
// searchAnswer is the part of a search answer SearchIDs and SearchPage read.
type searchAnswer struct {
Found int `json:"found"`

View File

@@ -367,3 +367,66 @@ func TestSyncEngineRegistration(t *testing.T) {
t.Fatal("a blank API key counts as configured")
}
}
func TestEngineDropIndex(t *testing.T) {
f, e := newFake(t)
f.answer("DELETE /ts/collections/albums", http.StatusOK, `{"name":"albums"}`)
existed, err := e.DropIndex(t.Context(), "albums")
if err != nil || !existed {
t.Fatalf("200: existed=%v err=%v", existed, err)
}
f.answer("DELETE /ts/collections/albums", http.StatusNotFound, `{"message":"Not Found"}`)
existed, err = e.DropIndex(t.Context(), "albums")
if err != nil || existed {
t.Fatalf("404: existed=%v err=%v, want already absent", existed, err)
}
f.answer("DELETE /ts/collections/albums", http.StatusInternalServerError, `{"message":"boom"}`)
if _, err := e.DropIndex(t.Context(), "albums"); statusOf(err) != http.StatusInternalServerError {
t.Fatalf("500: err=%v", err)
}
calls := f.take()
if len(calls) != 3 {
t.Fatalf("calls = %+v", calls)
}
for _, c := range calls {
if c.method != http.MethodDelete || c.path != "/ts/collections/albums" || c.key != testKey {
t.Fatalf("call = %+v", c)
}
}
existed, err = beachcomber.DropIndex(t.Context(), e, "a b")
if err != nil || existed {
t.Fatalf("through beachcomber.DropIndex: existed=%v err=%v", existed, err)
}
if got := f.take(); len(got) != 1 || got[0].path != "/ts/collections/a%20b" {
t.Fatalf("escaped path calls = %+v", got)
}
}
func TestEngineEnsureIndex(t *testing.T) {
f, e := newFake(t)
f.answer("POST /ts/collections", http.StatusCreated, `{"name":"albums"}`)
schema := map[string]any{"fields": []map[string]any{{"name": "title", "type": "string"}}}
if err := beachcomber.EnsureIndex(t.Context(), e, "albums", schema); err != nil {
t.Fatal(err)
}
calls := f.take()
if len(calls) != 2 || calls[0].method != http.MethodGet || calls[1].method != http.MethodPost || calls[1].path != "/ts/collections" {
t.Fatalf("first ensure calls = %+v", calls)
}
var created map[string]any
if err := json.Unmarshal([]byte(calls[1].body), &created); err != nil {
t.Fatal(err)
}
if created["name"] != "albums" || created["fields"] == nil {
t.Fatalf("create body = %s", calls[1].body)
}
f.answer("GET /ts/collections/albums", http.StatusOK, `{"name":"albums"}`)
if err := e.EnsureIndex(t.Context(), "albums", schema); err != nil {
t.Fatal(err)
}
calls = f.take()
if len(calls) != 1 || calls[0].method != http.MethodGet {
t.Fatalf("second ensure must send no create: %+v", calls)
}
}