Pular para o conteúdo

Escrita de dados

As leituras estão em Querying data. Esta página é a outra metade: tudo o que altera uma linha.

Cada método aqui passa pelo mesmo pipeline que uma escrita vinda de qualquer outro lugar passa — callbacks, relações e segurança em nível de linha continuam valendo. Nada disso é um atalho para burlar suas próprias regras; o que essas opções proporcionam é economizar um round trip, uma transação ou uma condição de corrida que você não precisa mais perder.

const newProduct = await client.data.products.create({
name: "New Product",
price: 29.99,
active: true
});
// With a specific ID
const newProduct = await client.data.products.create(
{ name: "Custom ID Product" },
"my-custom-id"
);

Insira a linha ou atualize aquela que já ocupa sua chave:

await client.data.products.upsert(
{ sku: "LAMP-01", name: "Desk lamp", price: 39 },
{ onConflict: ["sku"] }
);

O servidor decide qual das duas dentro de uma única transação, e recorre a INSERT … ON CONFLICT DO UPDATE, portanto, diferentemente de um findById seguido por um create-ou-update, ela não perde a condição de corrida entre os dois e, ao contrário do create, não falha quando a linha já existe.

Quando a linha existe, o upsert é uma atualização dela. Os campos que você envia são gravados e os que você omite mantêm seus valores armazenados — eles não são redefinidos para seu defaultValue. beforeSave e afterSave veem status: "existing" com os valores anteriores, e o histórico registra uma atualização. Uma chave que indica uma linha fora do escopo beforeQuery do chamador resulta em um 404.

onConflict tem como padrão a chave primária, que é o alvo incorreto para a maioria das escritas em que um upsert é utilizado: indexado por um id serial que é uma simples inserção, já que o chamador não sabe o id — portanto, uma importação executável novamente duplica todas as linhas na segunda execução. Em vez disso, especifique a chave natural. Ela deve conter uma garantia de exclusividade com a qual o banco de dados possa fazer a correspondência — validation: { unique: true } na propriedade ou nas colunas de um índice unique: true — e qualquer outra coisa resultará em um erro 400 listando os alvos que realmente existem, em vez de um erro gerado de dentro de uma transação.

O timestamp on_create e o criador user_on_create de uma linha que já existia são mantidos intactos: um conflito significa que sua criação é um fato do passado.

Since 0.25 upsert() na coleção de usuários (a que tem auth ativado) é recusado com um 400 (INVALID_CONFLICT_TARGET), assim como createMany com upsert nela. Criar um usuário e alterá-lo seguem regras diferentes — uma criação faz o hash da senha e envia o convite, uma atualização não pode rebaixar o último administrador — e um upsert só descobre qual dos dois é dentro da escrita. Chame create ou update em vez disso.

const updated = await client.data.products.update(42, {
name: "Updated Name",
price: 39.99
});

Em vez disso, um valor pode ser uma operação sobre o valor armazenado:

await client.data.posts.update(postId, {
views: { $inc: 1 },
tags: { $push: "featured" },
meta: { $merge: { lastSeen: Date.now() } }
});
Operador Tipo de propriedade Significado
$inc number adiciona (negativo para subtrair)
$push array anexa um valor, ou cada valor de um array
$pull array remove todas as ocorrências de um valor
$merge map faz a mesclagem superficial (shallow-merge) de um objeto

O motivo para recorrer a eles é a leitura que você deixa de fazer e a condição de corrida que essa leitura abriria. views = current + 1 significa buscar current primeiro, e duas requisições que leem 4 escreverão 5 — um incremento é perdido e nenhuma das respostas avisa isso. Compilada na instrução, a aritmética acontece dentro do bloqueio da linha (row lock).

Exatamente um operador por campo, e apenas em uma atualização: em uma linha que ainda não existe não há nada sobre o que operar, portanto, uma operação em um create, createMany ou upsert retorna 400. Um operador no tipo de propriedade errado ou um $operator com erro de digitação resulta em um 400 informando o nome do campo — nunca em um documento JSON gravado na coluna.

Uma operação responde à validation da propriedade como um valor, e isso inclui o valor que ela produz. { stock: { $inc: -1 } } em um stock com validation: { min: 0 } tem sucesso enquanto o estoque for pelo menos 1 e é recusado em 0 com o mesmo 400 (VALIDATION_CONSTRAINT) que { stock: -1 } recebe. O mesmo vale para max, moreThan, lessThan, positive e negative, e para a quantidade mínima e máxima de itens (min, max) de um array após um $push ou $pull. A verificação faz parte da instrução de atualização, então dois decrementos concorrentes de um estoque de 1 não podem ambos ter sucesso, e uma atualização recusada não grava nada.

Enquanto estiver offline, elas são recusadas em vez de enfileiradas: uma operação é avaliada em relação a um valor armazenado do qual o dispositivo não tem uma cópia atualizada, e uma linha otimista só poderia exibir o próprio marcador até que a fila fosse esvaziada.

await client.data.products.delete(42);

Em uma coleção com exclusão lógica, isso marca a linha em vez de removê-la. client.data.products.trash lista, conta, restaura e exclui definitivamente as linhas marcadas.

update e delete aceitam um ifMatch, de forma que uma escrita é recusada caso a linha tenha sido alterada desde que você a leu:

import { etagOf } from "@rebasepro/client";
const post = await client.data.posts.get(1);
await client.data.posts.update(1, { title: "New" }, { ifMatch: etagOf(post) });
// → RebaseApiError, status 412, if somebody edited it in between

Sem isso, o ciclo leitura-modificação-escrita adota a regra do “último a escrever vence” (last-writer-wins) sobre tudo o que você não enviou: dois editores com um segundo de diferença são bem-sucedidos, e a alteração do primeiro é perdida sem nenhum erro em lugar algum.

etagOf(row) lê a versão de uma linha originada de findById/get. Ela reside em uma chave não enumerável, portanto, nunca entra no tipo Row gerado, em um JSON.stringify ou em um spread para o próximo corpo de atualização. O valor é undefined para uma linha vinda de find(), do cache offline ou de um servidor que não envia ETag — e passar undefined não envia nenhuma pré-condição, fazendo com que a chamada acima se degrade para uma atualização comum em vez de lançar um erro.

Desde 0.25 Uma linha vinda de update() carrega a versão que essa escrita produziu, de modo que edições condicionais se encadeiam sem uma leitura entre elas. Uma linha que um callback de listenById() recebe carrega a versão que esse frame mostra:

import { etagOf } from "@rebasepro/client";
let shown: Record<string, unknown> | undefined;
client.data.posts.listenById(1, (post) => {
shown = post; // etagOf(shown) is the version this frame shows
});
// On save: refused with 412 if the row has moved on since that frame
await client.data.posts.update(1, { title: "New" }, { ifMatch: etagOf(shown) });

A partir de um navegador em outra origem — um frontend Vite em sua própria porta, um host app. chamando um host api. — o ETag só é legível porque o servidor o nomeia em Access-Control-Expose-Headers. O runtime do Rebase faz isso, junto com Retry-After, X-Request-ID, os cabeçalhos X-RateLimit-* e Preference-Applied; na 0.23 ele não expõe nenhum deles, então o etagOf é undefined entre origens ali. Um backend que configura seu próprio cors() precisa expor a mesma lista, ou o etagOf é sempre undefined ali e toda escrita condicional sai como incondicional.

Toda escrita resolve para a linha que foi gravada. Passe { returning: false } quando não precisar dela:

await client.data.events.create({ kind: "page_view" }, undefined, { returning: false });

Isso envia Prefer: return=minimal; o servidor responde com 204 para uma escrita única e apenas com os ids para um lote. O método então resolve para undefined (ou []), evitando que você use acidentalmente uma linha que o servidor nunca enviou. Vale a pena em importações e escritas do tipo “dispare e esqueça” (fire-and-forget) — o padrão é retornar a linha porque ela traz o que o servidor determinou.

Três operações escrevem várias linhas em uma única requisição e em uma única transação. Cada linha ainda passa pelo pipeline normal — callbacks, relações, segurança em nível de linha — portanto, um lote não é um atalho para burlar suas próprias regras; o ganho é um round trip e uma transação em vez de N de cada.

Todas as três são tudo ou nada. Se qualquer linha for rejeitada, nenhuma delas será gravada e o erro indicará o índice com problema.

// Create
await client.data.products.createMany([
{ name: "Widget", price: 9.99 },
{ name: "Gadget", price: 19.99 }
]);
// Update — each entry names its row and the fields to change
await client.data.orders.updateMany([
{ id: "o-1", data: { status: "shipped" } },
{ id: "o-2", data: { status: "shipped" } }
]);
// Delete — by id
await client.data.sessions.deleteMany(["s-1", "s-2"]);

O createMany recebe linhas simples (flat rows) porque uma linha sendo criada é suas colunas. O updateMany especifica o endereço separadamente, porque em uma tabela cuja chave é algo além do id — um sku, uma chave composta —, uma linha simples não tem como dizer se uma coluna é o endereço ou um valor a ser gravado. Isso reproduz exatamente o update(id, data) de linha única.

Uma exclusão em massa baseada em filtro é uma operação diferente e muito mais perigosa: a forma de falha é uma condição omitida ou digitada incorretamente esvaziar uma tabela, e isso não pode ser revisado no local da chamada da mesma forma que uma lista explícita. Leia primeiro, depois passe os ids pretendidos:

const stale = await client.data.sessions.findAll({
where: { expiresAt: ["<", cutoff] }
});
await client.data.sessions.deleteMany(stale.map(s => s.id as string));

Um cliente que nunca recebe a resposta não tem como saber se o lote foi confirmado (committed), então ele tenta novamente — e, sem uma chave, o servidor não consegue distinguir essa nova tentativa de um segundo lote autêntico. Passe uma chave de idempotência em qualquer coisa que possa ser reenviada:

const attemptKey = crypto.randomUUID();
await client.data.products.createMany(rows, { idempotencyKey: attemptKey });

Uma chave identifica uma única requisição, não um trabalho (job): ela é registrada para o método, o caminho e o corpo com os quais foi enviada. Reenviar exatamente essa requisição reproduz sua resposta; a mesma chave em uma requisição diferente é recusada com IDEMPOTENCY_KEY_REUSED (422). Portanto, gere uma por chamada em vez de reutilizar um id de negócio — um importId compartilhado entre o createMany e o deleteMany de uma mesma importação deixaria a exclusão silenciosamente sem ser executada.

Uma nova tentativa que chega enquanto a primeira tentativa ainda está sendo respondida recebe IDEMPOTENCY_KEY_IN_PROGRESS (409): envie-a novamente e ela será respondida com o resultado da primeira tentativa assim que for concluída. As chaves são respeitadas por 24 horas e apenas para um chamador autenticado — caso contrário, não há uma entidade (principal) à qual associá-la.

A fila offline define uma chave automaticamente a cada repetição (replay).

Os lotes são limitados no lado do servidor (1000 linhas por padrão), pois um lote mantém seus bloqueios durante toda a transação. Ultrapassar isso resulta em um erro BULK_TOO_LARGE que informa tanto o limite quanto a contagem de linhas enviada, portanto, divida em partes (chunks):

for (const chunk of chunks(rows, 1000)) {
await client.data.products.createMany(chunk, { upsert: true });
}

Uma fonte de dados que não consegue gravar atomicamente relata BULK_UNSUPPORTED em vez de executar silenciosamente escritas individuais em loop — o que não ofereceria nem a atomicidade nem o round trip único que você buscou ao usar um lote.

O createMany e similares operam em uma coleção de cada vez. client.batch() é a forma entre coleções: uma requisição, uma transação, tudo ou nada.

const result = await client.batch([
{ op: "create", collection: "orders",
values: { total: 40 }, ref: "order" },
{ op: "create", collection: "orderItems",
values: { orderId: { $ref: "order.id" }, sku: "A-1" } },
{ op: "update", collection: "stock",
id: "A-1", values: { count: { $inc: -1 } } },
{ op: "delete", collection: "carts", id: "c-9" }
]);
result.data; // [ order, item, stock, null ] — aligned to the operations
result.meta; // { operations: 4 }

op é create, update, upsert ou delete, e collection restringe values ao formato gerado de Insert ou Update daquela coleção — uma coluna que a coleção não tem é um erro de compilação, assim como um create sem uma coluna obrigatória. collection é o accessor, o nome que client.data.<accessor> usa (orderItems para o slug order_items); o cliente envia o slug, através do dicionário collections com o qual foi criado. Na 0.23 um batch tipado envia o accessor como está escrito, então uma coleção em snake_case não pode ser alcançada por ele, e uma coluna mal escrita não é um erro de compilação. Cada operação executa o pipeline que seu equivalente de linha única executaria — a mesma validação, callbacks e segurança em nível de linha, como o mesmo usuário.

Uma operação pode se identificar com ref; uma posterior pode usar { $ref: "<name>.<field>" } onde quer que um valor seja aceito, inclusive como um id e em qualquer profundidade dentro de values. Isso é resolvido para aquele campo da linha gravada pela operação nomeada.

É por isso que o método existe em vez de um loop sobre o createMany: a chave estrangeira do filho não existe até que o pai seja inserido, então os dois teriam que ser requisições separadas — e requisições separadas podem ser concluídas pela metade. A recuperação disso (ler de volta, descobrir qual metade foi gravada, desfazê-la) é um código que ninguém escreve.

Apenas referências para trás (backward references) são resolvidas. Uma referência para frente é recusada antes da abertura da transação, juntamente com coleções desconhecidas, campos desconhecidos, operações de campo inválidas e alvos de conflito inelegíveis — porque descobrir um erro na operação 40, de outra forma, custaria o rollback das 39 escritas anteriores.

O mesmo limite de 1000 operações de uma escrita em massa, pelo mesmo motivo: um lote mantém seus bloqueios durante toda a transação. Um update ou delete que especifique uma linha que não existe faz todo o lote falhar com um erro 404. Um backend cujo driver não possa garantir a atomicidade responde com BATCH_UNSUPPORTED em vez de entrar em loop.

idempotencyKey e returning funcionam da mesma forma que em qualquer outra escrita.