duxt gibt [einen einzigen Build-Hook](/reference/hooks) aus, und der übergibt
jede Seite jeder Quelle, jeder Version und jeder Sprache als flache Records.
Eine Seite, die ohnehin **Typesense** betreibt, macht daraus eine eigene
Collection — ohne die gerenderte Seite erneut zu crawlen und ohne duxts Quell-,
Versions- und Locale-Logik nachzubauen.

::callout{type="info" title="Der Dienst gehört dir, nicht duxt"}
duxt liefert kein Typesense-SDK, keine Zugangsdaten und keine Dienstkonfiguration aus. Die optionale Companion-Layer übernimmt die Integration; der Dienst und seine Schlüssel gehören dir. Ohne Provider oder eigenen Listener [baut duxt überhaupt keine Payload](/reference/hooks).
::

## Die Companion-Layer verwenden

Für die fertige Integration installierst du `@kirchdev/duxt-typesense` und erweiterst es nach duxt. Es synchronisiert den vollständigen Satz über eine Staging-Collection und einen Aliaswechsel, entfernt die vorherige verwaltete Collection und verbindet den bestehenden Suchdialog mit einem reinen Suchschlüssel. Die folgenden Schritte bleiben ein Rezept für eine eigene Synchronisation.

::package-managers{command="add @kirchdev/duxt-typesense"}
::

```ts [nuxt.config.ts]
export default defineNuxtConfig({
  extends: ['@kirchdev/duxt', '@kirchdev/duxt-typesense'],
  duxtTypesense: {
    endpoint: process.env.TYPESENSE_ENDPOINT,
    collection: 'docs',
    adminKey: process.env.TYPESENSE_ADMIN_KEY,
    searchKey: process.env.TYPESENSE_SEARCH_KEY
  }
})
```

Der Admin-Schlüssel gehört ausschließlich in die Build-Konfiguration. `searchKey` darf nur `documents:search` für diesen Alias erlauben, und Typesense muss Browseranfragen deiner Seite zulassen.

## Schritte

::steps

1. **Installiere den Client in deiner Seite.** Er ist eine Abhängigkeit zur
   Build-Zeit: die Synchronisation läuft in Node während `nuxt build`, nie im
   Browser.

   ::package-managers{command="add -D typesense"}
   ::

2. **Deklariere die Collection.** Jedes Feld eines duxt-Records bildet auf ein
   Typesense-Feld ab, und die beiden Herkunftsfelder sind das, wonach ein Leser
   filtert:

   ```ts [server/typesense-schema.ts]
   import type { CollectionCreateSchema } from 'typesense';

   export const docs: CollectionCreateSchema = {
     name: 'docs',
     fields: [
       { name: 'url', type: 'string' },
       { name: 'title', type: 'string' },
       { name: 'content', type: 'string' },
       { name: 'source', type: 'string', facet: true },
       { name: 'version', type: 'string', facet: true, optional: true },
       { name: 'locale', type: 'string', facet: true }
     ]
   };
   ```

   Als `CollectionCreateSchema` typisiert, denn Schritt 4 übergibt genau
   dieses Objekt an Typesense, um die Collection damit anzulegen.

   `id` fehlt in der Liste mit Absicht: Typesense behandelt `id` bereits als
   Primärschlüssel des Dokuments, und duxts `id` ist
   [ein Hex-Digest der URL](/reference/hooks) — als Schlüssel gültig, auf jeder
   Maschine identisch und unverändert, solange die Seite bleibt, wo sie ist. Das
   ist es, was der Synchronisation ein **Upsert** statt eines Neuaufbaus
   erlaubt.

   `version` ist `optional`, denn eine Quelle mit nur einer Version trägt keine.
   Aus `source` ist die Version bewusst herausgenommen, damit eine Facette
   darauf ein Release überlebt und `version` daneben das ist, was auf eine
   einzelne eingrenzt.

3. **Schreibe das Modul, das zuhört.** Ein Nuxt-Modul in deiner eigenen Seite
   ist die ganze Integration — `defineNuxtModule`, ein Hook, ein Import:

   ```ts [modules/typesense.ts]
   import { defineNuxtModule } from '@nuxt/kit';
   import { Client } from 'typesense';
   import type { DuxtSearchRecord } from '@kirchdev/duxt/search-records';
   import { docs } from '../server/typesense-schema';

   export default defineNuxtModule({
     meta: { name: 'typesense-sync' },
     setup(_options, nuxt) {
       const key = process.env.TYPESENSE_ADMIN_KEY;
       // Kein Schlüssel ist ein lokaler Build, kein Fehler: überspringen,
       // statt ihn zu brechen.
       if (!key) return;

       nuxt.hook('duxt:search:records', async (records) => {
         const client = new Client({
           nodes: [{ url: process.env.TYPESENSE_URL! }],
           apiKey: key
         });

         await sync(client, records);
       });
     }
   });
   ```

   Die Payload ist **eingefroren**. Bilde sie auf eigene Dokumente ab —
   überschreibe niemals einen Record und sortiere das Array nicht an Ort und
   Stelle, denn jeder andere Listener bekommt dieselben Objekte.

4. **Synchronisiere die ganze Menge und entferne, was fort ist.** Der Hook
   übergibt bei jedem Build die *vollständige* aktuelle Record-Menge, also ist
   die Menge selbst die Anweisung — alles, was die Collection hält und was nicht
   darin vorkommt, wurde gelöscht, verschoben oder umbenannt:

   ```ts [modules/typesense.ts]
   async function sync(client: Client, records: readonly DuxtSearchRecord[]) {
     const collection = client.collections(docs.name);

     // Typesense legt nichts von selbst an: ein Import in eine Collection,
     // die es nicht gibt, schlägt fehl — der erste Build legt sie also an.
     if (!(await collection.exists())) await client.collections().create(docs);

     await collection.documents().import(
       records.map((record) => ({ ...record })),
       { action: 'upsert' }
     );

     // Alles, was der Build nicht übergeben hat, steht nicht mehr auf der Seite.
     const kept = new Set(records.map((record) => record.id));
     const live = await collection.documents().export();

     const stale = live
       .split('\n')
       .filter(Boolean)
       .map((line) => JSON.parse(line).id as string)
       .filter((id) => !kept.has(id));

     // Stapelweise löschen: jede id landet im Filter-String, und eine
     // einzige große Umbenennung baute einen, den Typesense ablehnt.
     for (let index = 0; index < stale.length; index += 100) {
       const batch = stale.slice(index, index + 100).join(',');
       await collection.documents().delete({ filter_by: `id:[${batch}]` });
     }
   }
   ```

   Die Collection wird beim ersten Lauf angelegt und danach in Ruhe gelassen,
   und die veralteten ids werden **stapelweise** gelöscht — Typesense nimmt den
   Filter als einen String, und eine Umbenennung, die eine ganze Quelle
   verschiebt, baute sonst einen, den er ablehnt.

   ::callout{type="tip" title="Oder tausche stattdessen einen Alias"}
   Importiere in eine nach dem Build benannte Collection, richte dann einen
   Typesense-**Alias** darauf und wirf die vorherige weg. Jeder Build legt seine
   eigene Collection an, also braucht es weder die Existenzprüfung noch das
   stapelweise Löschen, und Leser sehen nie einen halb geschriebenen Index — zum
   Preis, bei jedem Build alles neu zu indizieren statt nur das Bewegte.
   ::

5. **Suche aus dem Browser mit einem reinen Suchschlüssel.** Der Admin-Schlüssel
   oben signiert Schreibvorgänge und darf nie eine Seite erreichen. Stelle einen
   **scoped, nur suchenden** Schlüssel für den Client aus und filtere über die
   Facetten, die das Schema deklariert hat:

   ```ts
   // `locale` ist die Sprache des INHALTS, nicht die Oberflächensprache.
   // `version` fehlt bei einer Quelle ohne Versionen — die Klausel kommt nur
   // dann dazu; fest eingebaut filterte sie auf `undefined`.
   const filters = [`locale:${locale}`];
   if (version) filters.push(`version:${version}`);

   const results = await client.collections('docs').documents().search({
     q: query,
     query_by: 'title,content',
     filter_by: filters.join(' && ')
   });
   ```

6. **Prüfe einen Build von Anfang bis Ende.** Lass einen Build mit gesetztem
   Schlüssel laufen und bestätige, dass die Collection einen Record für eine
   Seite in **jeder** Quelle, Version und Sprache hält, die die Seite ausliefert
   — nicht nur in der Standardsprache. Der erste Build legt die Collection an;
   jeder folgende schreibt per Upsert in dieselbe.

::

## Was die Records dir schon abnehmen

Vier Eigenschaften der Payload machen eine so kurze Synchronisation überhaupt
erst korrekt, und alle vier sind [der Vertrag des Hooks](/reference/hooks) und
nicht der Rat dieser Anleitung:

| Eigenschaft | Was sie der Synchronisation bringt |
| :--- | :--- |
| `id` leitet sich aus `url` ab | Ein stabiler Primärschlüssel, also genügt `upsert` und ein Neuaufbau ist nie nötig |
| Die Payload ist die **vollständige** Menge | Löschen ist ein Vergleich gegen einen Build, ohne Crawl und ohne Buchführung |
| duxt feuert nicht bei leerem Parse | Ein Build, der nichts geparst hat, kann dir nie sagen, den Index zu leeren |
| Die Reihenfolge ist deterministisch | Zwei Builds unterscheiden sich genau dort, wo die Seite sich unterschied |

Eine Seite, die `search: false` geschrieben hat, und eine Übersetzung in einer
Sprache, die die Seite nicht routet, erzeugen keine Records — also erreicht
keine von beiden Typesense.

## Daraus ein Paket machen

Das Modul oben hat ~40 Zeilen und gehört zu einer Seite. Sobald eine zweite
Seite es braucht, ist der Weg zum Teilen **eine Nuxt-Ebene**, kein Plugin: eine
Ebene trägt das Modul, seine `app.config`-Vorgaben und seine Komponenten in
einem versionierten Paket, und ein Konsument fügt sie neben duxt hinzu.

```ts [nuxt.config.ts]
export default defineNuxtConfig({
  extends: ['@kirchdev/duxt', 'duxt-typesense']
});
```

Diese Ebene besitzt die Zugangsdaten, die Dienstkonfiguration, die
Synchronisationspolitik und die Such-Oberfläche im Browser. duxt selbst bekommt
nichts davon —
[und genau darum ist der Hook so geschnitten, wie er ist](/reference/hooks).

## Checkliste

- [ ] Der Client ist eine Abhängigkeit **deiner** Seite, nicht der Ebene
- [ ] Der Admin-Schlüssel lebt in der Build-Umgebung und erreicht nie eine Seite
- [ ] Die Collection wird beim ersten Build angelegt und facettiert `source`, `version` und `locale`
- [ ] Nicht mehr übergebene Records werden entfernt — per **stapelweisem** Vergleich oder per Alias-Tausch
- [ ] Ein Build ohne Schlüssel überspringt die Synchronisation, statt zu scheitern
- [ ] Jede Quelle, Version und Sprache ist in der Collection vertreten