Pular para o conteúdo principal

v0.18: Scalar, downloads de arquivo tipados, Collections conscientes de filtros

· 12 min para ler
Nathaniel Tucker
Creator of Reactive Data Client

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:

Outras melhorias:

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▶

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.

Mudanças incompatíveis:

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

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

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.

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

#3868

Guia de migração​

Esta atualização exige atualizar todas as versões dos pacotes ao mesmo tempo.

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

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:

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

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

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);
}
}

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.

O codemod não consegue inferir essa dependência, então atualize esses schemas manualmente.
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.

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

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

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:

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,
};
}

Suporte à atualização​

Como sempre, se você tiver qualquer problema ou dúvida, fique à vontade para entrar no nosso Chat ou registrar um bug