v0.18: Scalar, descargas de archivos tipadas, Collections conscientes de filtros
v0.18 se centra en un modelado de datos más rico para valores que varían según el contexto de la petición, descargas tipadas más simples y una coincidencia de collections que refleja mejor los filtros reales de las APIs.
Nuevas funcionalidades:
- Schema Scalar - Campos de entidad dependientes de un lens (p. ej. valores específicos de un portafolio) sin mutar jamás la entidad subyacente
- Propiedad
contentde RestEndpoint - Descargas de archivos tipadas, respuestas de texto y streaming con una sola propiedad nonFilterArgumentKeysen resource() - Los argumentos de ordenamiento/paginación no fragmentan tus Collections
Otras mejoras:
- Detección automática de Content-Type binarios - Las imágenes, PDFs y otras respuestas binarias se manejan automáticamente sin configuración (#3868)
- Los tipos de body de los extenders de Collection coinciden con la semántica del método HTTP - Los extenders PATCH (
.move,.remove) aceptan bodies parciales; un RestEndpoint independiente deriva un body tipado a partir del schema de entidad de la Collection (#3910) - Se exporta
CollectionOptionsdesde@data-client/endpointy@data-client/restpara construir Collections con tipado (#3904)
import { Collection, Entity, RestEndpoint, Scalar } from '@data-client/rest'; export class Company extends Entity { id = ''; name = ''; price = 0; pct_equity = 0; shares = 0; } const PortfolioScalar = new Scalar({ lens: args => args[0]?.portfolio, key: 'portfolio', entity: Company, }); Company.schema = { pct_equity: PortfolioScalar, shares: PortfolioScalar, }; export const getCompanies = new RestEndpoint({ path: '/companies', searchParams: {} as { portfolio: string }, // `portfolio` is a lens, not a filter — the returned Company list is the same // regardless of lens. Dropping it from `argsKey` collapses every portfolio to // one Collection pk, so `Collection.queryKey()` finds the list on every // switch and `useSuspense` reuses it without refetching. schema: new Collection([Company], { argsKey: () => ({}) }), }); export const getPortfolioColumns = new RestEndpoint({ path: '/companies/columns', searchParams: {} as { portfolio: string }, schema: new Collection([PortfolioScalar], { argsKey: ({ portfolio }: { portfolio: string }) => ({ portfolio }), }), });
El ejemplo muestra las mismas entidades Company renderizadas a través de distintos lenses de portafolio. Los campos estables de la Entity se reutilizan, mientras que los valores dependientes del lens se seleccionan de celdas Scalar separadas.
- Schema.denormalize() recibe un delegate -
denormalize(input, args, unvisit)→denormalize(input, delegate). Afecta solo a implementaciones personalizadas de Schema. - Schema.normalize() recibe un delegate -
normalize(input, parent, key, args, visit, delegate)→normalize(input, parent, key, delegate). Afecta solo a implementaciones personalizadas de Schema.
¿Usas un agente de IA? Instala la skill de migración: ejecuta el codemod, registra
argsKey para los schemas que dependen de los argumentos y se encarga de los casos que el codemod
no puede resolver (campos de clase, asignación dinámica de métodos, bloques <script> de SFC, helpers personalizados):
- Skills
- OpenSkills
- Claude Code
npx skills add reactive/data-client --skill data-client-v0.18-migration
npx openskills install reactive/data-client/.agents/skills/data-client-v0.18-migration
claude plugin marketplace add reactive/data-client
claude plugin install migrations@data-client
De lo contrario, ejecuta el codemod directamente; cubre los casos comunes:
npx jscodeshift -t https://dataclient.io/codemods/v0.18.js --extensions=ts,tsx,js,jsx src/
Scalar
Scalar maneja campos de Entity cuyos valores dependen de un "lens" en tiempo de ejecución, como el portafolio, la moneda o la configuración regional seleccionados. Varios componentes pueden renderizar la misma entidad a través de distintos lenses simultáneamente, cada uno viendo los valores correctos, mientras la entidad en sí nunca cambia. Los valores dependientes del lens viven en una tabla de celdas separada y se unen al momento de desnormalizar a partir de los argumentos del endpoint.
Las referencias a name y price permanecen estables al cambiar de portafolio porque la entidad Company
en sí nunca cambia; solo lo hace la celda Scalar seleccionada por el lens actual. Una sola instancia de Scalar
puede servir tanto como campo de Entity.schema (la entidad padre se infiere de la visita) como
de forma independiente dentro de Values, [Scalar]
o Collection([Scalar]) para refrescos baratos de solo columnas (la entidad se vincula
explícitamente mediante entity). Las pks de las celdas se derivan de la clave del mapa o mediante
Scalar.entityPk(), que por defecto usa Entity.pk(), de modo que las claves primarias personalizadas y
compuestas funcionan sin necesidad de sobrescribir nada.
El store normalizado mantiene la entidad estable y las celdas del lens separadas:
entities['Company']['1'] = {
id: '1',
price: 145.20,
pct_equity: ['1', 'pct_equity', 'Company'],
shares: ['1', 'shares', 'Company'],
}
entities['Scalar(portfolio)']['Company|1|A'] = { pct_equity: 0.50, shares: 10000 }
entities['Scalar(portfolio)']['Company|1|B'] = { pct_equity: 0.30, shares: 6000 }
La demo combina getCompanies con un refresco barato de solo lens, getPortfolioColumns, que escribe
en la misma tabla de celdas; consulta la documentación de Scalar para un recorrido completo
del comportamiento de la caché.
Scalar también implementa queryKey, por lo que participa directamente en
useQuery, Controller.get y schema.Query,
toda la superficie Queryable, enumerando sus celdas para el lens actual.
#3887 - Documentación de Scalar
Propiedad content de RestEndpoint
RestEndpoint ahora acepta una propiedad content que controla
cómo se analiza el cuerpo de la respuesta. El tipo de retorno se infiere automáticamente a partir del valor, y schema queda
restringido a undefined a nivel de tipos para los tipos de contenido que no son JSON.
import { RestEndpoint } from '@data-client/rest';
const downloadFile = new RestEndpoint({
path: '/files/:id/download',
content: 'blob',
dataExpiryLength: 0,
});
// Return type is Blob — inferred from content, no explicit generics needed
const blob: Blob = await ctrl.fetch(downloadFile, { id: '123' });
Antes, las descargas de archivos requerían una sobrescritura verbosa de parseResponse. Ahora es una sola propiedad.
| Valor | Se analiza mediante | Tipo de retorno |
|---|---|---|
'json' | response.json() | any |
'blob' | response.blob() | Blob |
'text' | response.text() | string |
'arrayBuffer' | response.arrayBuffer() | ArrayBuffer |
'stream' | response.body | ReadableStream<Uint8Array> |
Establecer content en un valor que no sea JSON obliga a schema: undefined a nivel de tipos, ya que los datos binarios no pueden
normalizarse. Una comprobación en tiempo de ejecución ofrece un mensaje de error claro si se usa por accidente un schema normalizable.
Funciona con extend() y con subclases:
// Extend a JSON endpoint into a blob downloader
const download = jsonEndpoint.extend({
content: 'blob',
schema: undefined,
});
// Subclass for a reusable blob endpoint type
class BlobEndpoint<O extends RestGenerics = any> extends RestEndpoint<O> {
content = 'blob' as const;
}
#3868 - Documentación de content
nonFilterArgumentKeys en resource()
nonFilterArgumentKeys permite a resource()
declarar qué claves de argumentos no se usan para filtrar los resultados de la collection; normalmente opciones de ordenamiento,
paginación o visualización. Sin ella, cada combinación distinta de argumentos de ordenamiento o página crearía un bucket separado de
Collection, y los elementos recién creados no aparecerían en las listas con
argumentos de ordenamiento distintos aunque los datos subyacentes deberían coincidir.
import { Entity, resource } from '@data-client/rest';
class Post extends Entity {
id = '';
title = '';
group = '';
author = '';
}
const PostResource = resource({
path: '/:group/posts/:id',
searchParams: {} as { orderBy?: string; author?: string },
schema: Post,
nonFilterArgumentKeys: ['orderBy'],
});
Ahora group y author son claves de filtro (separan las Collections en buckets), pero orderBy se
comparte: PostResource.getList.push() agrega el nuevo post a todas las variantes de orderBy de un bucket
{ group, author } dado.
Acepta un string[], una RegExp o una función predicado:
nonFilterArgumentKeys: /^(orderBy|page|cursor)$/,
#3914 - Documentación de nonFilterArgumentKeys
Detección automática de Content-Type binarios
Cuando content no está establecido, parseResponse ahora detecta automáticamente los
Content-Type binarios y devuelve response.blob() en lugar de corromper los datos mediante .text().
Los tipos binarios como image/*, audio/*, video/*, font/*, application/octet-stream, application/pdf y
otros ahora se manejan automáticamente. Los tipos de texto (text/*, XML, HTML, JavaScript, CSS, etc.) siguen
usando el respaldo de texto existente con mensajes de error que tienen en cuenta el schema.
Esto significa que los endpoints que sirven datos binarios con cabeceras Content-Type correctas funcionan sin ninguna configuración:
const ep = new RestEndpoint({
path: '/avatars/:id',
});
// Server responds with Content-Type: image/png
// → automatically returns a Blob (previously corrupted via .text())
Guía de migración
Esta actualización requiere actualizar todas las versiones de los paquetes simultáneamente.
- NPM
- Yarn
- pnpm
- esm.sh
npm install --save @data-client/react@^0.18.0 @data-client/rest@^0.18.0 @data-client/endpoint@^0.18.0 @data-client/core@^0.18.0 @data-client/vue@^0.18.0 @data-client/test@^0.18.0 @data-client/img@^0.18.0
yarn add @data-client/react@^0.18.0 @data-client/rest@^0.18.0 @data-client/endpoint@^0.18.0 @data-client/core@^0.18.0 @data-client/vue@^0.18.0 @data-client/test@^0.18.0 @data-client/img@^0.18.0
pnpm add @data-client/react@^0.18.0 @data-client/rest@^0.18.0 @data-client/endpoint@^0.18.0 @data-client/core@^0.18.0 @data-client/vue@^0.18.0 @data-client/test@^0.18.0 @data-client/img@^0.18.0
<script type="importmap">
{
"imports": {
"@data-client/react": "https://esm.sh/@data-client/react@^0.18.0",
"@data-client/rest": "https://esm.sh/@data-client/rest@^0.18.0",
"@data-client/endpoint": "https://esm.sh/@data-client/endpoint@^0.18.0",
"@data-client/core": "https://esm.sh/@data-client/core@^0.18.0",
"@data-client/vue": "https://esm.sh/@data-client/vue@^0.18.0",
"@data-client/test": "https://esm.sh/@data-client/test@^0.18.0",
"@data-client/img": "https://esm.sh/@data-client/img@^0.18.0"
}
}
</script>
Los cambios incompatibles de esta versión afectan únicamente a las implementaciones personalizadas de Schema.
Si solo usas resource() y los schemas integrados (Entity,
Collection, Union, Values,
Array, Object, Query,
Invalidate, Lazy, Scalar), la actualización es directa.
¿Tienes un Schema personalizado? Si usas un agente de IA, instala la skill de migración y deja que dirija
todo el flujo: codemod, registro de argsKey y limpieza manual:
- Skills
- OpenSkills
- Claude Code
npx skills add reactive/data-client --skill data-client-v0.18-migration
npx openskills install reactive/data-client/.agents/skills/data-client-v0.18-migration
claude plugin marketplace add reactive/data-client
claude plugin install migrations@data-client
Sin un agente, ejecuta el codemod y luego lee las secciones siguientes para los casos que no puede
manejar (especialmente argsKey para los schemas que dependen de los argumentos):
npx jscodeshift -t https://dataclient.io/codemods/v0.18.js --extensions=ts,tsx,js,jsx src/
Schema.denormalize() recibe un delegate
Omite esta sección si no implementas clases Schema personalizadas.
Schema.denormalize() ahora es (input, delegate) en lugar de la firma anterior de 3 parámetros
(input, args, unvisit). El nuevo
IDenormalizeDelegate expone unvisit, args y un nuevo helper argsKey(fn).
#3887
import { Entity } from '@data-client/rest';
class Wrapper {
denormalize(
input: {},
args: readonly any[],
unvisit: (s: any, v: any) => any,
) {
const value = unvisit(this.schema, input);
return this.process(value, ...args);
}
}
import { Entity, type IDenormalizeDelegate } from '@data-client/rest';
class Wrapper {
denormalize(input: {}, delegate: IDenormalizeDelegate) {
const value = delegate.unvisit(this.schema, input);
return this.process(value, ...delegate.args);
}
}
El codemod maneja métodos de clase, métodos de objeto, declaraciones de función, firmas de interfaces de
TypeScript y llamadas de paso directo someSchema.denormalize(input, args, unvisit). También agrega
la importación de IDenormalizeDelegate como especificador type en línea en tu importación existente de
@data-client/* (o crea una nueva línea import type si no encuentra ninguna).
Salida dependiente de los argumentos
Leer delegate.args directamente no contribuye a la memoización. Si la salida de tu schema
depende de esos argumentos (p. ej. un lens), declara la dependencia mediante delegate.argsKey(fn) para que la
caché se divida en buckets correctamente.
class LensSchema {
denormalize(input, args, unvisit) {
const lens = args[0]?.portfolio;
return this.lookup(input, lens);
}
}
class LensSchema {
constructor({ lens }) {
this.lensSelector = lens; // ①
}
denormalize(input, delegate) {
const lens = delegate.argsKey(this.lensSelector); // ②
return this.lookup(input, lens);
}
}
- ①The function reference is the cache key, so set it once here (or at module scope). An inline arrow would miss the cache on every call.
- ②
argsKeyreturnsfn(args)and records the dependency for memoization.
Consulta Scalar para ver un ejemplo desarrollado.
Schema.normalize() recibe un delegate
Omite esta sección si no implementas clases Schema personalizadas.
Schema.normalize() ahora lee los argumentos del endpoint y el visitante recursivo desde
INormalizeDelegate, en línea con la nueva forma del delegate de denormalize.
La firma anterior era (input, parent, key, args, visit, delegate, parentEntity?);
la nueva firma es (input, parent, key, delegate, parentEntity?).
#3934
import type { INormalizeDelegate, Visit } from '@data-client/rest';
class Wrapper {
normalize(
input: any,
parent: any,
key: string,
args: readonly any[],
visit: Visit,
delegate: INormalizeDelegate,
) {
const normalized = visit(this.schema, input.value, input, 'value', args);
delegate.mergeEntity(this, this.pk(input, parent, key, args), normalized);
return normalized;
}
}
import type { INormalizeDelegate } from '@data-client/rest';
class Wrapper {
normalize(
input: any,
parent: any,
key: string,
delegate: INormalizeDelegate,
) {
const { args, visit } = delegate;
const normalized = visit(this.schema, input.value, input, 'value');
delegate.mergeEntity(this, this.pk(input, parent, key, args), normalized);
return normalized;
}
}
Si tu schema acepta el argumento opcional parentEntity, mantenlo como el último argumento:
normalize(input, parent, key, delegate, parentEntity) {
// ...
}
delegate.visit() ya lleva los mismos argumentos para la normalización recursiva, por lo que los
schemas anidados no necesitan recibir los argumentos explícitamente.
Aditivo: parentEntity en normalize
El callback visit() del delegate de normalize rastrea internamente el schema de tipo entidad
más cercano que lo contiene y lo reenvía a Schema.normalize() como el argumento final opcional
parentEntity. Los nuevos schemas pueden optar por descubrir la entidad que los contiene
al momento de normalizar (usado internamente por Scalar).
Opcional: consolidar las definiciones de Collection
Collection ahora puede usar tanto argsKey como
nestKey en la misma instancia. Como limpieza
opcional, reemplaza los pares de Collections de nivel superior y anidadas por una sola
definición compartida:
export const getTodos = new RestEndpoint({
path: '/todos',
searchParams: {} as { userId?: string },
schema: new Collection([Todo]),
});
class User extends Entity {
static schema = {
todos: new Collection([Todo], {
nestKey: parent => ({ userId: parent.id }),
}),
};
}
export const userTodos = new Collection([Todo], {
//argsKey: params => ({ ...params }), - this is default, so it's not needed
nestKey: parent => ({ userId: parent.id }),
});
export const getTodos = new RestEndpoint({
path: '/todos',
searchParams: {} as { userId?: string },
schema: userTodos,
});
class User extends Entity {
static schema = {
todos: userTodos,
};
}
Soporte para la actualización
Como siempre, si tienes algún problema o pregunta, no dudes en unirte a nuestro o reportar un error
