Datos y backends

Adaptadores de datos del lado del servidor más ejecutores de lista listos para PostgreSQL, MongoDB y Mongoose.

Datos asíncronos (server-side)

import { serverActionAdapter } from 'listkit'

const adapter = serverActionAdapter<Product>(async query => {
  const { rows, total } = await listProductsAction(query) // page/pageSize/search/filters
  return { data: rows, total }
})

<ListView config={productsConfig} adapter={adapter} />

Backend PostgreSQL (listkit/sql)

Para un backend Postgres, listkit/sql convierte un ListQuery en fragmentos SQL seguros — placeholders $n, lower() LIKE, NULLS LASTsin dependencia de driver. Compónlos tú mismo, o pásale un pool a executeSqlList para toda la query de página (filtros + búsqueda + scope + orden + paginación) en una llamada:

import { parseListkitQuery } from 'listkit/query'
import { executeSqlList } from 'listkit/sql'

app.get('/api/discounts', async (req, res) => {
	const { data, total } = await executeSqlList<Discount>({
		pool, // node-postgres / @vercel/postgres / @neondatabase/serverless — cualquier { query() }
		table: 'discount d',
		query: parseListkitQuery(req.query),
		fields: {
			kind: 'd.kind', // select  → igualdad
			value: 'd.value', // number-range → >= / <=
			created: 'd.created_at', // date-range
			// many-to-many con un builder `match` + la fábrica de placeholders `p(value)`:
			colors: {
				match: (v, p) =>
					Array.isArray(v) && v.length
						? `EXISTS (SELECT 1 FROM product_color j WHERE j.sku = d.sku AND j.id = ANY(${p(v.map(Number))}::int[]))`
						: null,
			},
		},
		searchColumns: ['d.label', 'd.code'],
		sort: { label: 'd.label', created: 'd.created_at' },
		fallbackSort: 'd.created_at DESC',
		tiebreak: ', d.id DESC',
		scope: { 'd.tenant_id': tenantId }, // alcance de auth fusionado en cada query
	})
	res.json({ data, total }) // la forma { data, total } que espera fetchAdapter
})

Las columnas vienen solo de los whitelists que controlas (sin inyección SQL) y el matching refleja el adapter en memoria. Para control total, baja a buildSqlFilter(query, fields, params) + buildSearch(term, columns, params) (ambos hacen append a tu params para que el numerado $n quede correcto) y buildOrderBy — el patrón manual exacto, sin el boilerplate. sqlFieldMapFromFilters(config.filters) deriva un field map inicial desde tu config.

executeSqlList acepta además searchNormalizer — el fold que se aplica a ambos lados, la columna y el término bindeado, así que expr => `unaccent(lower(${expr}))` hace que "Mexico" encuentre "México" (requiere la extensión unaccent); el término se bindea crudo justamente para que el fold lo alcance. Y maxExport honra un pageSize gigante como exportar-todo en vez de recortarlo a una página, igual que mongoPaginate. Pásale el mismo searchNormalizer a buildSqlExport para que una exportación devuelva exactamente las filas que mostró la lista.

Backend MongoDB (listkit/mongo)

El front-end es el mismo en cualquier app de React (fetchAdapter → tu endpoint REST). En el servidor, traduce el ListQuery entrante a objetos planos de Mongo con listkit/mongosin dependencia de mongoose/driver y nunca ejecuta una query, así que funciona con Mongoose o el driver nativo. Los nombres de campo provienen solo de listas blancas que tú controlas (sin inyección NoSQL) y los valores de texto se escapan para regex.

import { buildMongoQuery } from 'listkit/mongo'

// query es el ListQuery de listkit parseado desde la request
const { filter, sort, skip, limit } = buildMongoQuery(query, {
	fields: {
		legalName: 'legalName', // text  → $regex sin distinción de mayúsculas
		type: 'type', // select → igualdad
		status: 'csf.generalData.status', // ruta anidada, según el tipo de filtro
		created: 'createdAt', // date-range → $gte/$lte
		hasCsf: { path: 'csf', build: existenceMatch }, // un campo, expr custom
		// Bucket calculado sobre varios campos — `match` se fusiona tal cual:
		certStatus: {
			match: v =>
				v === 'active'
					? { cerFile: { $ne: null }, certificateValidTo: { $gt: new Date() } }
					: null,
		},
	},
	sort: { name: 'legalName', created: 'createdAt' },
	fallbackSort: { legalName: 1 },
})

const [data, total] = await Promise.all([
	Model.find(filter).sort(sort).skip(skip).limit(limit).lean(),
	Model.countDocuments(filter),
])
return { data, total } // la forma { data, total } que espera fetchAdapter

El matching refleja el motor in-memory. Texto, select y multi-select comparan sin acentos ni distinción de mayúsculas ('cancun' encuentra 'Cancún'), y un boolean en false también matchea documentos donde el campo nunca se escribió — las mismas filas que devolvería un memoryAdapter, garantizado por una suite de paridad que corre un mismo fixture por ambos motores contra un mongod real.

Dos escapes importan a escala:

fields: {
	// Valores controlados en un campo indexado: igualdad exacta, usa el índice.
	status: { path: 'status', fold: false },
	// Fechas guardadas como números Date.now() en vez de Date de BSON.
	created: { path: 'createdAt', as: 'unix-ms' },
}

Una comparación con folding es un regex, así que no puede usar un índice de igualdad. Para igualdad insensible a acentos a escala, usa un índice con collation ({ locale: 'es', strength: 1 }) y pasa collation al executor. La búsqueda libre es un regex no anclado por naturaleza: mantén searchFields corto, acompáñalo de un baseFilter indexado (un tenant, un dueño), y migra a Atlas Search cuando eso deje de alcanzar.

Una sola llamada de punta a punta. executeMongoList arma filtros, búsqueda, referencias, orden y paginación, y corre el find + count. No depende del driver — pásale la colección nativa o el .collection de un modelo de Mongoose:

import { executeMongoList } from 'listkit/mongo'
import { parseListkitQuery } from 'listkit/query'

app.get('/api/companies', async (req, res) => {
	const result = await executeMongoList({
		collection: db.collection('companies'),
		query: parseListkitQuery(req.query),
		fields: mongoFieldMapFromFilters(companiesConfig.filters ?? []),
		searchFields: ['legalName', 'taxId'],
		sort: { name: 'legalName', created: 'createdAt' },
		fallbackSort: { legalName: 1 },
		tiebreak: { _id: 1 }, // sin esto, los empates paginan de forma no determinista
		baseFilter: { organizationId: req.orgId },
	})
	res.json(result) // { data, total }
})

Filtros sobre una colección unida. resolveReferences convierte "filtrar ventas por el nombre de su cliente" en un $in de ids que matchean, con tope (10 000 por defecto) para que un filtro amplio no arrastre una colección entera a una sola query; buildMongoSearchWithRefs hace lo mismo para la búsqueda libre. /mongoose conecta ambos por ti vía references / searchReferences.

Migrar un endpoint existente. Si tu API ya responde { results, pagination }, conserva ese contrato mientras mueves las entrañas: envuelve con toLegacyEnvelope en el servidor y léelo con fromLegacyEnvelope como transformResponse del adapter hasta migrar el wire. encodeListQuery es la codificación canónica del cliente, exportada para que un adapter propio no se desincronice de parseListkitQuery.

Una entrada del field map es una ruta string de confianza, { path, build } para personalizar la expresión de un campo, o { match } para construir una condición completa fusionada tal cual — esto último es cómo un solo filtro abarca varios campos (buckets calculados, reglas entre campos). Combina condiciones extra (alcance de auth, id de tenant, un $in por referencia de una colección anidada) con combineFilters, y usa los helpers de más bajo nivel buildMongoFilter / buildMongoSort / mongoPaginate / existenceMatch cuando necesites control fino.

Evita la segunda copia. En lugar de escribir el whitelist fields a mano, derívalo de los mismos filters que tu config ya declara con mongoFieldMapFromFilters — así la UI del sidebar y la query del backend quedan sincronizadas desde una sola fuente. Los select de existencia (opciones with/without) se mapean a un spec existenceMatch automáticamente. Para filtros que apuntan a una colección poblada/unida, usa filterConfigToMongoFieldMaps(filters, { references }) para separarlos en { main, refs }:

import {
	buildMongoQuery,
	filterConfigToMongoFieldMaps,
	mongoFieldMapFromFilters,
} from 'listkit/mongo'

// Caso simple — una colección:
const fields = mongoFieldMapFromFilters(companiesConfig.filters ?? [])
const { filter, sort, skip, limit } = buildMongoQuery(query, {
	fields,
	sort: sortMap,
})

// Con una referencia poblada (p. ej. `csf.*` vive en una colección unida):
const { main, refs } = filterConfigToMongoFieldMaps(
	companiesConfig.filters ?? [],
	{
		references: { csf: 'csf' },
	}
)
// → main = filtros a nivel empresa; refs.csf = filtros de la colección csf

Ejecutor de Mongoose (listkit/mongoose)

Para un backend con Mongoose, listkit/mongoose corre toda la query de la página por ti — búsqueda, filtros avanzados, referencias pobladas, orden, paginación y un camino de exportar-todo — así un controller son pocas líneas. mongoose es un peer dependency opcional y type-only (se importa con import type, así que este entry no incluye runtime de mongoose y no agrega peso al bundle más allá de los constructores); instálalo en el backend para usar este entry.

import { parseListkitQuery } from 'listkit/query'
import { filterConfigToMongoFieldMaps } from 'listkit/mongo'
import { executePaginatedListkitQuery } from 'listkit/mongoose'

const maps = filterConfigToMongoFieldMaps(companiesConfig.filters ?? [], {
	references: { csf: 'csf' },
})

app.get('/api/companies', async (req, res) => {
	const { data, total } = await executePaginatedListkitQuery<Company>({
		model: CompanyModel,
		query: parseListkitQuery(req.query),
		fields: maps.main,
		references: [{ path: 'csf', model: CsfModel, fields: maps.refs.csf ?? {} }],
		searchFields: ['legalName', 'taxId'],
		searchReferences: [
			{ path: 'csf', model: CsfModel, fields: ['generalData.postalCode'] },
		],
		sortFields: { name: 'legalName', created: 'createdAt' },
		fallbackSort: { legalName: 1 },
		populate: ['csf'],
		baseFilter: { appsAllowed: req.app }, // alcance de auth, tenant id, …
	})
	res.json({ data, total }) // la forma { data, total } que espera fetchAdapter
})

Cada filtro de referencia activo se vuelve un $in de los ids de referencia que coinciden; el término de búsqueda matchea searchFields en la colección principal y (por id) searchReferences. Un pageSize mayor que maxPageSize (por defecto 100) se trata como exportar todo — desde la primera fila, con tope maxExport (por defecto 50 000) — así combina con el fetchAll de exportación de una lista. Cuando no necesitas referencias/populate, el más bajo nivel buildMongoQuery + tu propio Model.find sigue siendo lo más simple.

Para filas que un find no puede expresar — documentos con $unwind, un join con $lookup, una columna $addFields por la que el usuario filtra y ordena — executeAggregateListkitQuery es el executor hermano: las mismas opciones más tu pipeline. Reutiliza exactamente los mismos builders, así que la semántica de búsqueda/filtros/orden es idéntica incluido el casteo de valores. Eso último no sale gratis: un $match de agregación no castea por su cuenta, a diferencia de find, así que cada $match pasa antes por el schema del modelo (castFilterToSchema, exportado por si armas pipelines a mano). Un valor que el schema rechaza — un id malformado de un bookmark viejo — resuelve a un filtro que ninguna fila satisface, así que la lista vuelve vacía, no sin filtrar.

On this page