Pular para o conteúdo
Nelcota
EN : Read in English

@nelcota/client

10 min de leitura Editar no GitHub

Cliente JavaScript e TypeScript para Nelcota: API REST sob RLS, autenticação e armazenamento.

Comece pelo início rápido (também em português), pelos exemplos executáveis (seu esquema é examples/notes.sql) ou pelo contrato do cliente. Resultados vazios, 401/403 ou limite de 1000 linhas? Veja solução de problemas.

  • Enxuto. Operações correspondem diretamente ao HTTP; renovação de sessão e novas tentativas de leitura podem adicionar requisições. Não há cache oculto de dados, e o Postgres (GRANTs e RLS) decide o que uma chamada pode fazer.
  • Sem dependências de execução. Usa fetch, Web Crypto, AbortSignal, Web Locks e BroadcastChannel e executa em navegadores, Node 22+, Deno e Bun. Hosts de borda precisam das mesmas Web APIs e de validação específica do host. Apenas ESM, sem efeitos colaterais, com exportações por subcaminho (/rest, /auth, /storage).
  • Tipado a partir do seu esquema por nelcota types, incluindo os relacionamentos embutidos em uma string select.
  • Seguro por construção. Entradas nunca se tornam sintaxe de consulta, caminhos de armazenamento não podem sair do bucket e um token service_role é recusado no navegador.
shell
npm install @nelcota/[email protected]
nelcota types -o src/database.ts
TypeScript
import { createClient } from '@nelcota/client';
import type { Database } from './database';

export const nelcota = createClient<Database>('https://api.example.com');

Em JavaScript puro, omita o tipo gerado e o parâmetro genérico:

JavaScript
import { createClient } from '@nelcota/client';
const nelcota = createClient('https://api.example.com');

Escopo de versão

A instalação acima seleciona o pacote publicado 0.1.2. Esta referência descreve a fonte atual, incluindo alterações não publicadas. O registro transacional nativo de sessões/PKCE no IndexedDB, a preservação numérica exata e as correções recentes de propriedade no cancelamento não estão no pacote publicado 0.1.2. Leia o README incluído nesse pacote para o contrato da versão; use um pacote compilado localmente para testar a fonte. Atualizar o servidor não atualiza clientes instalados. Node é ferramenta/runtime do SDK, não requisito do backend.

Módulos independentes

JavaScript
import { createAuthClient } from '@nelcota/client/auth';
import { createRestClient } from '@nelcota/client/rest';
import { createStorageClient } from '@nelcota/client/storage';

const auth = createAuthClient('https://api.example.com');
const options = { accessToken: () => auth.accessToken() };
const rest = createRestClient('https://api.example.com', options);
const storage = createStorageClient('https://api.example.com', options);

Cada entrada inclui seu transporte sem importar os outros módulos de recursos. createRestClient<Database>(url) também aceita tipos de esquema gerados.

Resultados e erros

Cada chamada resolve para { data, error } e nunca lança exceções por falhas HTTP ou de rede. error é um NelcotaError:

  • status: o status HTTP ou 0 quando nenhuma resposta chegou.
  • code: o código do servidor intacto (user_already_exists, invalid_grant, db_error, rate_limited...) ou um código do cliente: network_error, timeout, aborted, invalid_response, not_single, session_missing, pkce_missing. Uma resposta sem corpo JSON (contagem HEAD, página de proxy) recebe unauthorized, forbidden, not_found, rate_limited, unavailable ou http_<status>. O tipo NelcotaErrorCode lista os códigos conhecidos e ainda aceita qualquer string.
  • retryAfter: segundos a esperar após um 429 ou 503.
  • sqlstate, details, hint, constraint: o que o Postgres informou para um db_error (23505, Key (slug)=(a) already exists., notes_slug_key), quando o servidor os envia; undefined caso contrário.

Um erro de programação (nome de coluna inválido, .. no caminho de arquivo, token service_role no navegador) lança NelcotaUsageError antes de qualquer envio.

Adaptadores personalizados de token/armazenamento podem lançar seus próprios erros. Uma resposta retornada por storage.open() pertence ao chamador: trate erros ao ler seu stream.

TypeScript
const { data, error } = await nelcota.from('notes').select('id,body');
if (error) console.error(error.code, error.message);

Para usar exceções, chame .throwOnError() em uma consulta (seu tipo de resultado passa a não ter ramo de erro) ou passe qualquer resultado para unwrap, que retorna data ou lança o NelcotaError:

TypeScript
import { unwrap } from '@nelcota/client';

const { data: notes } = await nelcota.from('notes').select('id,body').throwOnError();
const user = unwrap(await nelcota.auth.getUser());
const blob = unwrap(await nelcota.storage.from('files').download('a.txt'));

Tabelas, views e funções

TypeScript
// Read: columns, *, embeds in either direction, filters, order, paging.
const { data, count } = await nelcota
  .from('orders')
  .select('id,total,customer:customers(name),items(product,qty)', { count: 'exact' })
  .eq('status', 'paid')
  .gte('items.qty', 2)                                  // filters the embedded rows
  .order('created_at', { ascending: false })
  .order('qty', { referencedTable: 'items' })
  .range(0, 19);
// data: { id: number | string; total: number | string; customer: { name: string } | null;
//         items: { product: string; qty: number }[] }[]

// One row: an error (not_single) unless exactly one matches.
const { data: order } = await nelcota.from('orders').select().eq('id', 10).single();

// Only the count (a HEAD request, no rows).
const { count: open } = await nelcota.from('orders').select('*', { count: 'exact', head: true }).eq('status', 'open');

// Groups: conditions are built with typed calls, never strings.
await nelcota.from('products').select().or((c) => [
  c.eq('status', 'featured'),
  c.and([c.gt('price', 100), c.not(c.is('archived', true))]),
]);

// Writes return nothing unless you ask for the rows with select().
await nelcota.from('notes').insert({ body: 'hi' });
const { data: created } = await nelcota.from('notes').insert({ body: 'hi' }).select('id').single();
await nelcota.from('notes').update({ body: 'edited' }).eq('id', 1);
await nelcota.from('notes').delete().in('id', [1, 2, 3]);
await nelcota.from('products').upsert(rows, { onConflict: 'slug' });      // or ignoreDuplicates: true

// Functions run with the caller's role.
const { data: total } = await nelcota.rpc('cart_total', { cart_id: 7 });

Filtros: eq, neq, gt, gte, lt, lte, like, ilike, in, is (null, true, false, 'unknown'), not(column, op, value), match({...}), or(...) e and(...). Um caminho de coluna como orders.total filtra um relacionamento embutido. Builders são imutáveis, então uma consulta-base pode ser reutilizada.

like/ilike interpretam * (ou %) como "qualquer texto". Envolva o texto digitado por uma pessoa em escapeLike para que % e _ correspondam literalmente:

TypeScript
import { escapeLike } from '@nelcota/client';
nelcota.from('products').select().ilike('name', `*${escapeLike(search)}*`);

update e delete precisam de pelo menos um filtro; caso contrário, o servidor os recusa. count: 'exact' varre todas as linhas correspondentes, então solicite apenas quando for mostrar a contagem.

Consultas HEAD retornam data: null. single() e maybeSingle() exigem linhas: use .select() primeiro nas escritas e omita head: true.

Paginação

O servidor retorna no máximo NELCOTA_MAX_ROWS linhas por requisição (1000 por padrão), mesmo sem limit. Pagine com range(from, to) ou limit(n) + offset(n) e leia range (as linhas retornadas, de Content-Range) e count (com count: 'exact') para saber se há mais linhas:

TypeScript
const { data, range, count } = await nelcota
  .from('notes').select('id,body', { count: 'exact' }).order('id').range(0, 49);
const more = range !== null && count !== null && range.to + 1 < count;

Para percorrer todas as linhas, pages(size) solicita uma página após a outra até uma vazia, então um limite do servidor menor que size não encerra a leitura antes da hora. Ordene por uma coluna única para que páginas não se sobreponham nem pulem linhas; uma página com falha lança seu NelcotaError:

TypeScript
for await (const page of nelcota.from('notes').select('id,body').order('id').pages(500)) {
  for (const note of page) console.log(note.id);
}

Autenticação

TypeScript
await nelcota.auth.signUp({ email, password, data: { name: 'Ana' } });  // session is null while the email awaits confirmation
await nelcota.auth.signInWithPassword({ email, password });
await nelcota.auth.signOut();

const { data: session } = await nelcota.auth.getSession();   // refreshed first when about to expire
const { data: user } = await nelcota.auth.getUser();         // the server's view, now
await nelcota.auth.updateUser({ data: { plan: 'pro' } });     // merged into user_metadata; null removes a key
await nelcota.auth.updateUser({ password: next, currentPassword: password }); // ends the other sessions

const stop = nelcota.auth.onChange((event, session) => {
  // 'signed_in' | 'signed_out' | 'refreshed' | 'user_updated', also from other tabs
});

Sessões. Em navegadores, o localStorage nativo usa um registro transacional no IndexedDB como estado oficial da sessão e espelha as sessões confirmadas no localStorage. Nos demais ambientes, as sessões ficam em memória. Passe auth: { storage } para usar um adaptador personalizado, como um adaptador de cookies para renderização no servidor. Qualquer implementação com getItem/setItem/removeItem funciona, de forma síncrona ou assíncrona.

O armazenamento do navegador pode ser lido por qualquer script da página, então se aplicam os cuidados usuais contra XSS: uma Content Security Policy e nenhum HTML não confiável. A sessão contém um token de acesso de curta duração, por padrão de 15 minutos, e um token de renovação que é rotacionado a cada uso.

Renovação. O token de acesso é renovado em segundo plano antes de expirar e novamente logo antes de uma requisição, quando necessário.

O servidor encerra uma sessão cujo token de renovação é usado duas vezes, então as renovações são coordenadas:

  • Uma renovação por vez em um processo, independentemente de quantas requisições aguardam.
  • Entre abas, um Web Lock serializa as renovações. Um registro confirmado no IndexedDB entrega a sessão substituta ao próximo proprietário mesmo quando sua visão do localStorage está desatualizada. Uma reserva é confirmada antes do HTTP.

A persistência nativa no navegador exige IndexedDB. Falhas de armazenamento rejeitam chamadas explícitas de autenticação; observe falhas de renovação automática com auth.onBackgroundError. Se um proprietário desaparecer durante a rotação ou não conseguir salvar a sessão substituta, outro cliente se recusa a reutilizar o token reservado. Repita a tentativa pelo cliente proprietário, faça login novamente ou chame signOut() para limpar a sessão.

Sem Web Locks, uma fila local coordena clientes na mesma instância do módulo. O registro nativo impede que proprietários concorrentes usem um token reservado, mas não faz com que aguardem a sessão substituta. Adaptadores compartilhados personalizados precisam de Web Locks ou coordenação externa; use auth: { storage: sessionStorage } para sessões separadas por aba. Mantenha a mesma chave de armazenamento ao compartilhar um adaptador de sessão.

Na atualização, o registro importa uma vez a sessão existente do localStorage. Recarregue todas as abas abertas para evitar misturar os protocolos de coordenação antigo e novo. Use signOut() ou limpe todos os dados do site para remover sessões nativas; editar apenas localStorage não altera mais o estado oficial da sessão.

Falhas ao publicar login ou logout preservam o estado local mais recente enquanto a revisão anterior exata do registro permanece atual, mesmo se outro cliente tiver gravado essa revisão anterior. Uma nova revisão confirmada a substitui, incluindo uma publicação explícita do mesmo valor.

Se uma renovação falhar por motivo transitório, o token antigo ainda é enviado. A requisição então falha com 401 em vez de executar silenciosamente como visitante.

OAuth (Google, GitHub) usa PKCE, com o verificador mantido neste navegador:

TypeScript
// Login page: navigates to the provider.
await nelcota.auth.signInWithOAuth({ provider: 'github', redirectTo: 'https://app.example.com/auth/callback' });

// On the callback page: reads ?code= (or ?error=), cleans the address bar, signs in.
const { data, error } = await nelcota.auth.handleRedirect();

redirectTo deve estar listado em NELCOTA_OAUTH_REDIRECT_URLS do servidor. Cada chave de storage mantém um verificador OAuth pendente: iniciar outro login substitui a tentativa anterior. A gravação do verificador, o redirecionamento e o consumo no callback usam o mesmo bloqueio para impedir que operações assíncronas invertam essa ordem. Use chaves de storage diferentes para fluxos de login independentes.

A publicação e o consumo nativos de PKCE também usam o registro no IndexedDB, para que um localStorage desatualizado não forneça um verificador antigo nem reutilize um já consumido. Uma troca já cancelada preserva seu verificador. Após o início do consumo, a troca com prazo limitado persiste uma sessão aceita mesmo se o chamador cancelar a espera; o timeout da primeira troca continua valendo.

Links de e-mail chegam como https://your.app/page#type=...&token=.... readEmailLink() extrai o token e o remove da barra de endereço e do histórico:

TypeScript
const link = nelcota.auth.readEmailLink();
if (link?.type === 'magiclink' || link?.type === 'signup') await nelcota.auth.verifyEmailLink(link);
if (link?.type === 'recovery') await nelcota.auth.resetPassword({ token: link.token, password: newPassword });

await nelcota.auth.sendMagicLink(email);
await nelcota.auth.requestPasswordReset(email);   // same answer whether or not the account exists
await nelcota.auth.resendConfirmation(email);

No servidor, encaminhe o token do chamador em vez de manter uma sessão:

TypeScript
// Validate the header in middleware before constructing the client.
const nelcota = createClient<Database>(url, { accessToken: () => validatedCallerToken });

Crie esse cliente por requisição. validatedCallerToken é um token ou null vindo de middleware que valida a sintaxe Bearer e retorna 401 para cabeçalhos malformados. Apenas a ausência de cabeçalho significa acesso anônimo; não transforme autenticação inválida em visitante. O exemplo de servidor demonstra validação e encaminhamento para RLS. accessToken desativa renovação automática; métodos de autenticação chamados explicitamente ainda armazenam sessões.

Métodos HTTP de autenticação recebem { signal, timeout } como último argumento. Chamadas concorrentes de renovação compartilham o timeout do primeiro chamador; cada chamador pode cancelar sua própria espera de forma independente. Após iniciar, a rotação termina e salva o novo token mesmo se uma espera for cancelada. O cancelamento REST/armazenamento também cobre a espera por um token ou renovação de sessão. signOut() aguarda uma renovação pendente. Chame nelcota.dispose() (ou auth.dispose() para o cliente independente) para parar timers e fechar canais quando o cliente não for mais usado.

Um token service_role ignora a RLS. Use-o apenas em servidor confiável; no navegador, o cliente o recusa.

Armazenamento

TypeScript
const files = nelcota.storage.from('avatars');

await files.upload(`${user.id}/me.png`, file);                       // 409 object_exists if taken
await files.upload(`${user.id}/me.png`, file, { upsert: true });     // replace
const { data: blob } = await files.download(`${user.id}/me.png`);
const { data: listing } = await files.list({ prefix: `${user.id}/` });
await files.remove(`${user.id}/me.png`);

const { data: url } = await files.createSignedUrl('reports/q1.pdf', 3600);  // anyone with the URL, for an hour
files.publicUrl('logo.png');                                                 // public buckets, no request
  • Uploads enviam os bytes como estão (sem multipart). Um ReadableStream é transmitido sem carregar tudo em memória onde o ambiente suporta isso (Node, Deno, Bun; navegadores por HTTP/2).
  • open(name, { range, ifNoneMatch, download }) retorna a Response bruta, para transmitir response.body, retomar com intervalos (206) ou revalidar com ETags (304).
  • Nomes são normalizados em NFC e codificados segmento por segmento. Segmentos vazios, . e .., caracteres de controle e barras invertidas são recusados.
  • Buckets são gerenciados com createBucket, updateBucket, deleteBucket, getBucket e listBuckets, geralmente como service_role. updateBucket substitui todas as configurações e exige os três campos:
TypeScript
await nelcota.storage.updateBucket('avatars', {
  public: true,
  file_size_limit: 5_000_000,
  allowed_mime_types: ['image/png', 'image/jpeg'],
});

Use null explícito para remover um limite de tamanho ou MIME. Atualizações parciais são recusadas antes do HTTP, para que um campo omitido não remova silenciosamente uma restrição existente.

Quem pode ler ou escrever cada arquivo depende das políticas de storage.objects; veja a documentação de armazenamento do servidor.

Receitas

Consultar alterações periodicamente

Não há canal realtime. Para uma UI mostrando as 100 notas mais recentes, atualize um snapshot limitado. O exemplo pressupõe um cliente nelcota inicializado e uma função render que substitui a lista exibida:

TypeScript
async function poll(signal: AbortSignal) {
  const { data, error } = await nelcota.from('notes')
    .select('id,body,created_at').order('id', { ascending: false })
    .limit(100).abortSignal(signal);
  if (error) return error.retryAfter ?? 30;
  render(data);
  return 5;
}

function delay(seconds: number, signal: AbortSignal): Promise<void> {
  if (signal.aborted) return Promise.resolve();
  return new Promise((resolve) => {
    const finish = () => {
      clearTimeout(timer);
      signal.removeEventListener('abort', finish);
      resolve();
    };
    const timer = setTimeout(finish, seconds * 1000);
    signal.addEventListener('abort', finish, { once: true });
  });
}

const controller = new AbortController();
const finished = (async () => {
  while (!controller.signal.aborted) {
    const wait = document.hidden ? 60 : await poll(controller.signal);
    await delay(wait, controller.signal);
  }
})();
// On page/component teardown: controller.abort(); await finished; nelcota.dispose();

Isso atualiza o estado visível da UI; não é entrega durável de eventos nem exportação de todo o histórico. Timestamps ou IDs crescentes não garantem entrega exatamente uma vez: timestamps iguais, limites de página e ordem de commit das transações podem fazer receitas com cursor perderem linhas. Use ordenação estável na paginação. Para mudanças confirmadas, use o contrato de webhooks do servidor com deduplicação durável no receptor.

Renderização no servidor com cookies

Em um servidor que renderiza páginas para usuários autenticados, mantenha a sessão em cookies em vez de memória, com um cliente criado por requisição. O adaptador abaixo funciona com a API de cookies de qualquer framework (Next.js cookies(), Express req.cookies/res.cookie, Hono, SvelteKit...):

TypeScript
import { createClient, type SessionStorage } from '@nelcota/client';

interface CookieJar {
  get(name: string): string | undefined;
  set(name: string, value: string, options: Record<string, unknown>): void;
  delete(name: string): void;
}

export function cookieStorage(jar: CookieJar): SessionStorage {
  // Normalize this application's chosen key into a cookie name.
  // Use distinct fixed storageKey values; this mapping is not collision-free.
  const name = (key: string) => key.replace(/[^\w-]/g, '_');
  return {
    getItem: (key) => {
      const value = jar.get(name(key));
      return value === undefined ? null : decodeURIComponent(value);
    },
    setItem: (key, value) =>
      jar.set(name(key), encodeURIComponent(value), {
        httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 30,
      }),
    removeItem: (key) => jar.delete(name(key)),
  };
}

// In a request handler:
const nelcota = createClient<Database>(process.env.NELCOTA_URL!, {
  auth: { storage: cookieStorage(jar), storageKey: 'nelcota', autoRefresh: false },
});
const { data: notes } = await nelcota.from('notes').select('id,body');

httpOnly mantém tokens longe dos scripts da página. Este adaptador é ilustrativo: metadados do usuário podem exceder o limite de tamanho de cookies do navegador, e percent encoding aumenta seu tamanho. Use sessões limitadas ou armazenamento no servidor com um identificador opaco no cookie; trate falhas de decodificação e armazenamento. Handlers do framework precisam poder gravar/excluir cookies com path/domain correspondentes. Proteja rotas que alteram estado contra CSRF. Nunca autorize apenas com metadados de usuário em cache; o servidor deve verificar o token do solicitante.

Renovações de requisições diferentes precisam de coordenação compartilhada. Um bloqueio dentro do processo não serializa várias instâncias do servidor; centralize a rotação em um coordenador compartilhado e permita que handlers reutilizem a substituição aceita. autoRefresh: false (já padrão fora de navegadores) evita timers em segundo plano em um cliente por requisição; um token perto de expirar ainda é renovado antes da requisição.

Opções

TypeScript
createClient<Database>(url, {
  auth: { storage, storageKey, autoRefresh, refreshMargin },
  accessToken,           // () => token | null, replaces the session
  fetch,                 // custom fetch (tests, tracing)
  headers,               // extra headers (browsers only send what CORS allows)
  timeout: 30_000,       // ms per attempt, including buffered bodies; file transfers default to none
  retries: 2,            // reads only, on 429/503 and network errors, honouring Retry-After
});

Cada builder aceita .abortSignal(signal) e .timeout(ms); as outras chamadas aceitam { signal, timeout }.

Novas tentativas aguardam até 30 segundos por Retry-After. Um atraso maior retorna o erro imediatamente com retryAfter, permitindo ao aplicativo decidir quando tentar novamente.

Compatibilidade

@nelcota/client Versão-base do servidor Suporte
0.1.x Servidor v0.3.0 ou posterior Suíte completa de contratos, rotas OAuth, updateUser, cabeçalhos CORS expostos e Relationships gerados
0.1.x Servidor v0.2.0 Não suportado: faltam as rotas OAuth, PUT /auth/v1/user, o contrato JSON de erros, Relationships gerados e os cabeçalhos expostos mais recentes

A verificação de publicação cobre Node 22/24/26, Bun, Deno e os motores Chromium, Firefox e WebKit. Outros ambientes de execução precisam de validação própria.

Desenvolvimento

shell
npm ci
npm run check && npm test && npm run test:types   # types, unit and type-level tests
npm run build && npm run size                     # dist/ and the size budget per entry
npm run test:package                              # install the tarball in JS/TS consumers
node test/runtime/smoke.mjs
bun test/runtime/smoke.mjs
deno run --allow-net --allow-read --allow-env test/runtime/smoke.mjs
cargo build -p nelcota-server
npx playwright-core install chromium firefox webkit
npm run test:contract -- --browser                # against the real binary (Docker), plus Chromium

A execução do contrato inicia Postgres e Mailpit em contêineres e o servidor a partir de target/debug, aplica test/contract/fixture.sql e gera os tipos com nelcota types. Depois executa os testes de contrato (incluindo fuzz da codificação de consultas) e, com --browser, uma página Chromium em outra origem.

Defina NELCOTA_SDK_BROWSERS=chromium,firefox,webkit para exercitar todos os navegadores. As verificações incluem a demonstração executável, renovação simultânea em duas abas reais e broadcasts de logout. Cada execução tem nomes únicos de contêineres e portas atribuídas dinamicamente, então pode coexistir com serviços de desenvolvimento.

Veja releasing.md (no repositório) para configuração da conta, verificações da versão candidata e procedimento de publicação por tag. CHANGELOG.md acompanha as alterações do SDK.