duxt emite [un único hook de compilación](/reference/hooks), y entrega cada
página de cada fuente, versión e idioma como registros planos. Un sitio que ya
opera **Typesense** convierte esos registros en una colección propia — sin
volver a rastrear el sitio renderizado y sin reimplementar la lógica de fuentes,
versiones y locales de duxt.

::callout{type="info" title="El servicio es tuyo, no de duxt"}
duxt no incluye SDK de Typesense, credenciales ni configuración del servicio. La capa complementaria opcional se encarga de la integración; el servicio y sus claves son tuyos. Sin un proveedor o listener personalizado, duxt [no construye ninguna carga útil](/reference/hooks).
::

## Usar la capa complementaria

Para la integración empaquetada, instala `@kirchdev/duxt-typesense` y extiéndela después de duxt. Sincroniza el conjunto completo mediante una colección temporal y un cambio de alias, elimina la colección anterior administrada y conecta el diálogo de búsqueda existente con una clave de solo búsqueda. Los pasos siguientes siguen siendo una receta para una sincronización personalizada.

::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
  }
})
```

Mantén la clave de administración exclusivamente en la configuración de compilación. `searchKey` debe permitir únicamente `documents:search` para este alias, y Typesense debe admitir solicitudes del navegador desde tu sitio.

## Pasos

::steps

1. **Instala el cliente en tu sitio.** Es una dependencia de compilación: la
   sincronización corre en Node durante `nuxt build`, nunca en el navegador.

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

2. **Declara la colección.** Cada campo de un registro de duxt se asigna a un
   campo de Typesense, y los dos campos de procedencia son por los que filtra un
   lector:

   ```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 }
     ]
   };
   ```

   Tipado como `CollectionCreateSchema`, porque el paso 4 entrega este mismo
   objeto a Typesense para crear la colección con él.

   `id` no está en la lista a propósito: Typesense ya trata `id` como la clave
   primaria del documento, y el `id` de duxt es
   [un resumen hexadecimal de la URL](/reference/hooks) — válido como clave,
   idéntico en cualquier máquina y sin cambios mientras la página siga donde
   está. Eso es lo que permite que la sincronización haga **upsert** en vez de
   reconstruir.

   `version` es `optional`, porque una fuente con una sola versión no lleva
   ninguna. A `source` se le quita la versión deliberadamente, para que una
   faceta construida sobre él sobreviva a una publicación y `version` a su lado
   sea lo que reduce a una.

3. **Escribe el módulo que escucha.** Un módulo de Nuxt en tu propio sitio es
   toda la integración — `defineNuxtModule`, un hook, un 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;
       // Sin clave es una compilación local, no un fallo: sáltala en vez de
       // romperla.
       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);
       });
     }
   });
   ```

   La carga útil está **congelada**. Asígnala a documentos propios — nunca
   reescribas un registro ni ordenes el array en el sitio, porque a cada otro
   oyente se le entregan los mismos objetos.

4. **Sincroniza el conjunto completo y elimina lo que ya no está.** El hook
   entrega el conjunto *completo* y actual en cada compilación, así que el
   conjunto mismo es la instrucción — todo lo que la colección guarde y no esté
   en él se ha borrado, movido o renombrado:

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

     // Typesense no crea nada implícitamente: importar a una colección que
     // no existe falla sin más, así que la primera compilación la crea.
     if (!(await collection.exists())) await client.collections().create(docs);

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

     // Todo lo que la compilación no entregó ya no está en el sitio.
     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));

     // Borra por lotes: cada id entra en la cadena del filtro, y un solo
     // renombrado grande construiría uno que Typesense rechaza.
     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}]` });
     }
   }
   ```

   La colección se crea en la primera ejecución y se deja en paz después, y los
   ids obsoletos se borran **por lotes** — Typesense toma el filtro como una
   sola cadena, así que un renombrado que mueva una fuente entera construiría
   uno que rechaza.

   ::callout{type="tip" title="O intercambia un alias"}
   Importa a una colección nombrada por la compilación, apunta un **alias** de
   Typesense hacia ella y descarta la anterior. Cada compilación crea su propia
   colección, así que no hacen falta ni la comprobación de existencia ni el
   borrado por lotes, y los lectores nunca ven un índice a medio escribir — a
   cambio de reindexar todo en cada compilación en lugar de solo lo que se
   movió.
   ::

5. **Busca desde el navegador con una clave de solo búsqueda.** La clave de
   administración de arriba firma escrituras y jamás debe llegar a una página.
   Emite una clave **con alcance y solo de búsqueda** para el cliente, y filtra
   por las facetas que declaró el esquema:

   ```ts
   // `locale` es el idioma del CONTENIDO, no el idioma de la interfaz.
   // `version` no existe en una fuente sin versiones: la cláusula solo se
   // añade cuando la hay; fija, filtraría por `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. **Comprueba una compilación de principio a fin.** Ejecuta una compilación con
   la clave puesta y confirma que la colección guarda un registro de una página
   en **cada** fuente, versión e idioma que el sitio sirve — no solo el
   predeterminado. La primera compilación crea la colección; cada una posterior
   hace upsert en la misma.

::

## Lo que los registros ya resuelven por ti

Cuatro propiedades de la carga útil son lo que hace correcta una sincronización
tan corta, y las cuatro son [el contrato del hook](/reference/hooks) y no el
consejo de esta guía:

| Propiedad | Qué le da a la sincronización |
| :--- | :--- |
| `id` se deriva de `url` | Una clave primaria estable, así que `upsert` basta y nunca hace falta reconstruir |
| La carga útil es el conjunto **completo** | Eliminar es comparar contra una compilación, sin rastreo ni contabilidad |
| duxt no dispara con un análisis vacío | Una compilación que no analizó nada nunca puede decirte que vacíes el índice |
| El orden es determinista | Dos compilaciones difieren exactamente donde difirió el sitio |

Una página que escribió `search: false`, y una traducción en un idioma que el
sitio no enruta, no producen registros — así que ninguna llega a Typesense.

## Convertirlo en un paquete

El módulo de arriba tiene ~40 líneas y pertenece a un solo sitio. En cuanto un
segundo sitio lo necesite, la forma de compartirlo es **una capa de Nuxt**, no
un plugin: una capa lleva el módulo, sus valores por defecto de `app.config` y
sus componentes en un paquete versionado, y un consumidor la añade junto a duxt.

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

Esa capa es dueña de las credenciales, la configuración del servicio, la
política de sincronización y la interfaz de búsqueda en el navegador. duxt no
gana ninguna de ellas —
[que es la razón entera de la forma del hook](/reference/hooks).

## Lista de comprobación

- [ ] El cliente es una dependencia de **tu** sitio, no de la capa
- [ ] La clave de administración vive en el entorno de compilación y nunca llega a una página
- [ ] La colección se crea en la primera compilación y facetea `source`, `version` y `locale`
- [ ] Los registros que ya no se entregan se eliminan — por comparación **por lotes** o por intercambio de alias
- [ ] Una compilación sin la clave se salta la sincronización en vez de fallar
- [ ] Cada fuente, versión e idioma está representada en la colección