v0.18: Scalar, downloads de arquivo tipados, Collections conscientes de filtros
A v0.18 foca em uma modelagem de dados mais rica para valores que variam conforme o contexto da requisição, downloads tipados mais simples e uma correspondência de collections que reflete melhor os filtros reais das APIs.
Novos recursos:
- Schema Scalar - Campos de entidade dependentes de lente (por exemplo, valores específicos de um portfólio) sem jamais mutar a entidade subjacente
- Propriedade
contentdo RestEndpoint - Downloads de arquivo tipados, respostas em texto e streaming com uma única propriedade nonFilterArgumentKeysem resource() - Argumentos de ordenação/paginação não fragmentam suas Collections
Outras melhorias:
- Detecção automática de Content-Type binário - Imagens, PDFs e outras respostas binárias são tratadas automaticamente, sem nenhuma configuração (#3868)
- Os tipos de body dos extenders de Collection seguem a semântica dos métodos HTTP - Extenders PATCH (
.move,.remove) aceitam bodies parciais; um RestEndpoint independente deriva um body tipado do schema de entidade da Collection (#3910) - Exporta
CollectionOptionsde@data-client/endpointe@data-client/restpara a construção tipada de Collections (#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 }), }), });
O exemplo mostra as mesmas entidades Company renderizadas através de diferentes lentes de portfólio. Os campos estáveis da Entity são reutilizados, enquanto os valores dependentes de lente são selecionados de células Scalar separadas.
- Schema.denormalize() recebe um delegate -
denormalize(input, args, unvisit)→denormalize(input, delegate). Afeta apenas implementações personalizadas de Schema. - Schema.normalize() recebe um delegate -
normalize(input, parent, key, args, visit, delegate)→normalize(input, parent, key, delegate). Afeta apenas implementações personalizadas de Schema.
Usa um agente de IA? Instale a skill de migração — ela executa o codemod, registra
argsKey para schemas dependentes de args e trata os casos que o codemod
não consegue (campos de classe, atribuição dinâmica de métodos, blocos <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
Caso contrário, execute o codemod diretamente — ele cobre os casos comuns:
npx jscodeshift -t https://dataclient.io/codemods/v0.18.js --extensions=ts,tsx,js,jsx src/
Scalar
Scalar trata campos de Entity cujos valores dependem de uma "lente" em tempo de execução — como o portfólio, a moeda ou o locale selecionado. Vários componentes podem renderizar a mesma entidade através de lentes diferentes simultaneamente, cada um vendo os valores corretos, enquanto a entidade em si nunca muda. Os valores dependentes de lente vivem em uma tabela de células separada e são unidos no momento da desnormalização a partir dos args do endpoint.
As referências a name e price permanecem estáveis ao trocar de portfólio porque a entidade Company
em si nunca muda — apenas a célula Scalar selecionada pela lente atual. Uma única instância de Scalar
pode servir tanto como campo de Entity.schema (entidade pai inferida a partir da visita) quanto
de forma independente dentro de Values, [Scalar]
ou Collection([Scalar]) para atualizações baratas somente de colunas (entidade vinculada
explicitamente via entity). Os pks das células são derivados da chave do mapa ou via
Scalar.entityPk(), que por padrão usa Entity.pk(), de modo que chaves primárias personalizadas e
compostas funcionam sem precisar sobrescrever nada.
A store normalizada mantém a entidade estável e as células de lente 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 }
A demonstração combina getCompanies com uma atualização barata somente de lente, getPortfolioColumns, que escreve
na mesma tabela de células — veja a documentação de Scalar para um passo a passo completo
do comportamento de cache.
Scalar também implementa queryKey, então participa diretamente de
useQuery, Controller.get e schema.Query —
toda a superfície Queryable — enumerando suas células para a lente atual.
#3887 - Documentação de Scalar
Propriedade content do RestEndpoint
RestEndpoint agora aceita uma propriedade content que controla
como o corpo da resposta é interpretado. O tipo de retorno é inferido automaticamente a partir do valor, e schema fica
restrito a undefined no nível de tipos para tipos de conteúdo que não sejam 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, downloads de arquivo exigiam uma sobrescrita verbosa de parseResponse. Agora é uma única propriedade.
| Valor | Interpreta via | Tipo de retorno |
|---|---|---|
'json' | response.json() | any |
'blob' | response.blob() | Blob |
'text' | response.text() | string |
'arrayBuffer' | response.arrayBuffer() | ArrayBuffer |
'stream' | response.body | ReadableStream<Uint8Array> |
Definir content com um valor que não seja JSON impõe schema: undefined no nível de tipos, já que dados binários não podem ser
normalizados. Uma verificação em tempo de execução fornece uma mensagem de erro clara caso um schema normalizável seja usado por acidente.
Funciona com extend() e com subclasses:
// 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 - Documentação de content
nonFilterArgumentKeys em resource()
nonFilterArgumentKeys permite que resource()
declare quais chaves de argumento não são usadas para filtrar os resultados da collection — tipicamente opções de ordenação,
paginação ou exibição. Sem isso, cada combinação distinta de args de ordenação ou página criaria um bucket separado de
Collection, e itens recém-criados não apareceriam em listas com
args de ordenação diferentes, mesmo que os dados subjacentes devessem corresponder.
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'],
});
Agora group e author são chaves de filtro (elas separam Collections em buckets distintos), mas orderBy é
compartilhado — PostResource.getList.push() adiciona o novo post a todas as variantes de orderBy de um dado
bucket { group, author }.
Aceita um string[], um RegExp ou uma função predicado:
nonFilterArgumentKeys: /^(orderBy|page|cursor)$/,
#3914 - Documentação de nonFilterArgumentKeys
Detecção automática de Content-Type binário
Quando content não está definido, parseResponse agora detecta automaticamente Content-Types
binários e retorna response.blob() em vez de corromper os dados via .text().
Tipos binários como image/*, audio/*, video/*, font/*, application/octet-stream, application/pdf e
outros agora são tratados automaticamente. Tipos textuais (text/*, XML, HTML, JavaScript, CSS etc.) continuam a
usar o fallback de texto existente, com mensagens de erro que levam o schema em conta.
Isso significa que endpoints que servem dados binários com cabeçalhos Content-Type corretos funcionam sem nenhuma configuração:
const ep = new RestEndpoint({
path: '/avatars/:id',
});
// Server responds with Content-Type: image/png
// → automatically returns a Blob (previously corrupted via .text())
Guia de migração
Esta atualização exige atualizar todas as versões dos pacotes ao mesmo tempo.
- 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>
As mudanças incompatíveis desta versão afetam apenas implementações personalizadas de Schema.
Se você usa apenas resource() e os schemas embutidos (Entity,
Collection, Union, Values,
Array, Object, Query,
Invalidate, Lazy, Scalar), a atualização é direta, sem alterações.
Tem um Schema personalizado? Se você usa um agente de IA, instale a skill de migração e deixe que ela conduza
todo o fluxo — codemod, registro de argsKey e limpeza 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
Sem um agente, execute o codemod e depois leia as seções abaixo para os casos que ele não consegue
tratar (especialmente argsKey para schemas dependentes de args):
npx jscodeshift -t https://dataclient.io/codemods/v0.18.js --extensions=ts,tsx,js,jsx src/
Schema.denormalize() recebe um delegate
Pule esta seção se você não implementa classes Schema personalizadas.
Schema.denormalize() agora é (input, delegate) em vez da assinatura anterior de 3 parâmetros,
(input, args, unvisit). O novo
IDenormalizeDelegate expõe unvisit, args e um novo 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);
}
}
O codemod trata métodos de classe, métodos de objeto, declarações de função, assinaturas de interface
TypeScript e chamadas de repasse someSchema.denormalize(input, args, unvisit). Ele também adiciona
o import de IDenormalizeDelegate como um especificador type inline no seu import
@data-client/* existente (ou cria uma nova linha import type se nenhum for encontrado).
Saída dependente de args
Ler delegate.args diretamente não contribui para a memoização. Se a saída do seu schema
depende desses args (por exemplo, uma lente), declare a dependência por meio de delegate.argsKey(fn) para que o
cache seja separado em buckets corretamente.
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.
Veja Scalar para um exemplo completo.
Schema.normalize() recebe um delegate
Pule esta seção se você não implementa classes Schema personalizadas.
Schema.normalize() agora lê os args do endpoint e o visitante recursivo de
INormalizeDelegate, seguindo o formato do novo delegate de denormalize.
A assinatura anterior era (input, parent, key, args, visit, delegate, parentEntity?);
a nova assinatura é (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;
}
}
Se o seu schema aceita o argumento opcional parentEntity, mantenha-o como o último argumento:
normalize(input, parent, key, delegate, parentEntity) {
// ...
}
delegate.visit() já carrega os mesmos args para a normalização recursiva, então
schemas aninhados não precisam receber os args explicitamente.
Aditivo: parentEntity em normalize
O callback visit() do delegate de normalize rastreia internamente o schema do tipo entidade
mais próximo que o envolve e o repassa para Schema.normalize() como o argumento final opcional
parentEntity. Novos schemas podem optar por descobrir a entidade que os contém
no momento da normalização (usado internamente por Scalar).
Opcional: consolidar definições de Collection
Collection agora pode usar tanto argsKey quanto
nestKey na mesma instância. Como limpeza
opcional, substitua pares de Collections de nível superior e aninhadas por uma única definição
compartilhada:
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,
};
}
Suporte à atualização
Como sempre, se você tiver qualquer problema ou dúvida, fique à vontade para entrar no nosso ou registrar um bug
