Saltar al contenido principal

v0.18: Scalar, descargas de archivos tipadas, Collections conscientes de filtros

· 13 min de lectura
Nathaniel Tucker
Creator of Reactive Data Client

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:

Otras mejoras:

Fixtures
▶api/Company
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 }),
  }),
});
▶CompanyGrid
▶PortfolioGrid
Resultado
Store▶

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.

Cambios incompatibles:

¿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):

npx skills add reactive/data-client --skill data-client-v0.18-migration

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.

ValorSe analiza medianteTipo de retorno
'json'response.json()any
'blob'response.blob()Blob
'text'response.text()string
'arrayBuffer'response.arrayBuffer()ArrayBuffer
'stream'response.bodyReadableStream<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())

#3868

Guía de migración​

Esta actualización requiere actualizar todas las versiones de los paquetes simultáneamente.

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

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:

npx skills add reactive/data-client --skill data-client-v0.18-migration

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

Before
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);
}
}
After
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.

El codemod no puede inferir esta dependencia, así que actualiza estos schemas a mano.
Before
class LensSchema {
denormalize(input, args, unvisit) {
const lens = args[0]?.portfolio;
return this.lookup(input, lens);
}
}
After
class LensSchema {
constructor({ lens }) {
this.lensSelector = lens; // ①
}
denormalize(input, delegate) {
const lens = delegate.argsKey(this.lensSelector); // ②
return this.lookup(input, lens);
}
}
  1. ①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.
  2. ②argsKey returns fn(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

Before
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;
}
}
After
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:

Before
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 }),
}),
};
}
After
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 Chat o reportar un error