npm package: @isomx/meilisearch-index-mgr
Description: A helper around the Meilisearch JavaScript client that makes it easier to drive rich, reactive search UIs. It wraps a single Meilisearch Index with an RxJS-powered queue, optional debouncing, easy in-flight cancellation, and an optional local cache of hit
Author: Josh Cope
Latest version: 4.3.7

# Summary
A helper around the Meilisearch JavaScript client that makes it easier to drive rich, reactive search UIs. It wraps a single Meilisearch Index with an RxJS-powered queue, optional debouncing, easy in-flight cancellation, and an optional local cache of hits for instant access later.

# Features
- Observable stream of results you can subscribe to from any component
- Optional debouncing for fast, low-latency typing experiences
- Auto-abandon in-flight requests when a new one is queued
- Simple local cache of hits with configurable cache key and case-normalization
- "Load more" helper that appends results to your existing hits

# Installation
- Peer dependency: meilisearch ^0.54
- Dependency: rxjs ^6.6

Use your package manager of choice:
- npm: npm i @isomorix/meilisearch-index-mgr
- yarn: yarn add @isomorix/meilisearch-index-mgr
- pnpm: pnpm add @isomorix/meilisearch-index-mgr

# Quick start
- Create a Meilisearch client and index
- Create an IndexMgr instance
- Subscribe to results and/or fire requests

### Example
```javascript
import { MeiliSearch } from 'meilisearch';
import { IndexMgr } from '@isomorix/meilisearch-index-mgr';

const client = new MeiliSearch({ host: 'http://127.0.0.1:7700' });
const index = client.index('movies');

const mgr = new IndexMgr(index, {
  debounce: 200,
  autoAbandonInFlight: true,
  cache: true,
  cacheKey: 'id',
  lowerCaseCacheKeys: false,
});

// Render from anywhere by subscribing
const sub = mgr.subscribe(({ resp, query, error }) => {
  if (error) {
    console.error('Search failed', error);
    return;
  }
  if (!resp) return;
  console.log('Results for', query, resp.hits);
});

// Fire a request from a different component (no subscription required here)
mgr.next({ query: 'batman', options: { limit: 10 } });

// Later, when done
sub.unsubscribe();
mgr.complete();
```

# Common UI patterns

### Debounced search input (vanilla JS)
```javascript
const input = document.querySelector('#search');
input.addEventListener('input', (e) => {
  mgr.next({ query: e.target.value, options: { limit: 5 } });
});
// Debouncing is handled by the mgr options.
```

### Autocomplete dropdown (vanilla JS)
- Subscribe to mgr and render a dropdown under your input
- Use `payload.options.attributesToHighlight` and highlight pre/post tags to style matches

Example:
```javascript
const dropdown = document.querySelector('#ac');

const acSub = mgr.subscribe(({ resp }) => {
  if (!resp) return;
  dropdown.innerHTML = resp.hits.map(h => `<div class="item">${h.title}</div>`).join('');
  dropdown.hidden = resp.hits.length === 0;
});

input.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') dropdown.hidden = true;
});
```

### Load more / infinite scroll
- The response is enriched with `payload.hasMore` and `payload.getMore(limit?)`, also available on `payload.resp.hasMore` and `payload.resp.getMore(limit?)`.
- Call `payload.getMore()` (or `payload.resp.getMore()`) to fetch the next page. Returned hits are appended to `payload.resp.hits` and a new Array is set on the provided `payload` containing all results.

Example:
```javascript
const loadMoreBtn = document.querySelector('#loadMore');

const listSub = mgr.subscribe(({ resp }) => {
  if (!resp) return;
  renderList(resp.hits);
  loadMoreBtn.disabled = !resp.hasMore;
});

loadMoreBtn.addEventListener('click', () => {
  const current = mgr.getResp();
  if (current && current.getMore) {
    // Optionally pass a new limit for the next page
    current.getMore(20).subscribe();
  }
});
```

### Using the local cache
- Enable cache with cache: true (or provide your own object)
- Choose a cacheKey that uniquely identifies a document (e.g., id or slug)
- Retrieve items quickly without hitting the server: mgr.getFromCache(key)

Example:

```javascript
const hit = mgr.getFromCache('movie-123');
if (hit) {
  // Render instantly while you also kick off a background refresh
}
```

### Abandoning requests (e.g., fast typing)
- Use mgr.get(payload) if you want an observable you can unsubscribe to cancel
- Or set autoAbandonInFlight: true and use mgr.next(payload) to auto-cancel previous requests

Example:

```javascript
const subReq = mgr.get({ query: 'super', options: { limit: 5 } }).subscribe(({ resp }) => {
  // Handle results
});
// Cancel in-flight
subReq.unsubscribe();
```

### React example (functional)

```javascript
import React, { useEffect, useMemo, useState } from 'react';
import { MeiliSearch } from 'meilisearch';
import { IndexMgr } from '@isomorix/meilisearch-index-mgr';

export function MovieSearch() {
  const [hits, setHits] = useState([]);
  const [query, setQuery] = useState('');

  const mgr = useMemo(() => {
    const client = new MeiliSearch({ host: 'http://127.0.0.1:7700' });
    return new IndexMgr(client.index('movies'), { debounce: 200, autoAbandonInFlight: true });
  }, []);

  useEffect(() => {
    const sub = mgr.subscribe(({ resp }) => {
      if (resp) setHits(resp.hits);
    });
    return () => { sub.unsubscribe(); mgr.complete(); };
  }, [mgr]);

  useEffect(() => { mgr.next({ query, options: { limit: 8 } }); }, [mgr, query]);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." />
      <ul>{hits.map(h => <li key={h.id}>{h.title}</li>)}</ul>
    </div>
  );
}
```

### Vue 3 example
```vue
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { MeiliSearch } from 'meilisearch';
import { IndexMgr } from '@isomorix/meilisearch-index-mgr';

const query = ref('');
const hits = ref([]);
let mgr;

onMounted(() => {
  const client = new MeiliSearch({ host: 'http://127.0.0.1:7700' });
  mgr = new IndexMgr(client.index('movies'), { debounce: 200, autoAbandonInFlight: true });
  const sub = mgr.subscribe(({ resp }) => { if (resp) hits.value = resp.hits; });
  // store subscription if you need to unsubscribe later
});

function onInput(e) {
  mgr.next({ query: e.target.value, options: { limit: 8 } });
}

onBeforeUnmount(() => { mgr?.complete(); });
</script>
<template>
  <input :value="query" @input="onInput" placeholder="Search movies" />
  <ul>
    <li v-for="h in hits" :key="h.id">{{ h.title }}</li>
  </ul>
</template>
```

# API summary

## IndexMgr constructor
`new IndexMgr(index: Meili Index, options?: IndexMgrOptions)`

- index: `client.index('name')` from meilisearch
- options:
  - `debounce?: number` — milliseconds to delay before running the latest queued request
  - `cache?: true | object` — enable caching of hits into the object
  - `cacheKey?: string` — property name on each hit used as the cache key
  - `lowerCaseCacheKeys?: boolean` — normalize keys to lowercase
  - `preparePayload?: (payload) => payload` — hook to modify payloads before execution
  - `autoAbandonInFlight?: boolean` — cancel in-flight request when next() queues a new one

## IndexMgr methods
- `get(payload): Observable` — queue a request and return an observable that emits the payload when done. Unsubscribe to cancel in-flight.
- `next(payload): void` — queue a request without returning an observable. Results are emitted to subscribers of the IndexMgr instance.
- `execute(payload): Observable` — run one-off request without touching cache or notifying subscribers.
- `subscribe(observerOrNext?): Subscription | Observable` — subscribe to payloads with results; if no arg, returns the observable stream.
- `reset(): IndexMgr` — clears cache (if enabled) and last value.
- `complete(): void` — dispose and complete internal streams.
- `getValue(): payload | null` — last completed payload.
- `getResp(): response | undefined` — last completed response.
- `getFromCache(key: string): object | undefined` — retrieve a cached hit.

## Payload (what you pass to get()/next()/execute())
- `query?: string` — search query
- `options?: SearchParams` — meilisearch search options
- `prevHits?: any[]` — when using getMore internally, previous hits are merged with the next page
- `resp?: SearchResponse` — set on completion
- `hasMore?: boolean` — indicates if more results are available
- `getMore?: (limit?: number) => Observable` — helper to fetch next page and append to `resp.hits`
- `error?: any` — set if the search fails

## TypeScript support
- This package ships types at types/index.d.ts.
- After installing, editors/TS should resolve types automatically.
- You can parameterize the document shape: `IndexMgr<MyDoc>`

Example:
```typescript
type Movie = { id: string; title: string; }; 
const mgr = new IndexMgr<Movie>(index, { cache: true, cacheKey: 'id' });
const sub = mgr.subscribe(({ resp }) => {
  const titles: string[] = (resp?.hits ?? []).map(h => h.title);
});
```

# Best practices and tips
- Use `debounce` option to debounce typing to reduce load and avoid flicker; `150–300`ms is common.
- Use `autoAbandonInFlight` in UIs where a new keystroke makes prior results irrelevant.
- Keep cacheKey stable (id/uuid/slug) if you enable caching; combine with `lowerCaseCacheKeys` for human-entered keys.
- Show loading states by tracking the time between queuing and receiving a response.
- Use `getMore()` to build infinite scroll; it automatically appends to resp.hits.
- Always clean up: `unsubscribe()` subscriptions you own and call `indexMgr.complete()` when disposing the IndexMgr.

# Error handling
- Subscribe to errors by inspecting `payload.error` in your subscription callback.
- Network cancellations (AbortError) when you cancel are expected; consider ignoring those in UI.

# SSR notes
- This library is UI/runtime focused. If using with SSR, instantiate and dispose inside request or per-view lifecycles to avoid leaking subscriptions.