Escrita via REST
Os verbos estão na página da REST API. Esta página aborda as cinco coisas que uma escrita pode solicitar além do seu verbo, e o endpoint que escreve em várias coleções de uma só vez. Todas elas são opcionais por requisição: uma escrita que não solicita nada disso se comporta exatamente como sempre se comportou.
Escrita
Seção intitulada “Escrita”Além dos verbos, as rotas de escrita aceitam cinco coisas que alteram o
comportamento da escrita. Todas as cinco são opcionais por requisição, portanto nada
aqui altera o que uma requisição que não as solicita faz. As rotas aninhadas
(/api/data/authors/42/posts) executam o mesmo código de escrita que as rotas raiz,
então tudo nesta seção vale para elas também, exceto ?on_conflict=.
O corpo de uma escrita precisa ser um objeto JSON. null, um número, uma string ou
um array resulta em 400 BAD_REQUEST.
Uma atualização não pode alterar a chave de uma linha. Um corpo que nomeia a
chave com outro valor resulta em 400 KEY_IMMUTABLE, antes de qualquer hook
rodar e sem nada escrito, seja qual for o caminho pelo qual a atualização
chega: REST, o socket de tempo real, MCP ou o rebase.data em processo. A
chave que a linha já tem é aceita, como a de um formulário que envia a linha
inteira de volta. Para mover uma linha para uma nova chave, crie-a lá e exclua
a antiga. Na 0.23 essa atualização responde 500, e uma em processo já moveu
a linha quando lança o erro.
Idempotência
Seção intitulada “Idempotência”Idempotency-Key: <uuid> em qualquer escrita significa “se você já respondeu a esta
requisição exata, responda novamente em vez de executá-la duas vezes”.
curl -X POST /api/data/orders \ -H "Idempotency-Key: 1f0f…" \ -d '{"total": 40}'Um cliente que nunca recebe uma resposta não tem como saber se a escrita foi confirmada, então ele tenta novamente — e, sem uma chave, o servidor não consegue distinguir essa nova tentativa de uma segunda escrita legítima. Em uma tabela com ID atribuído pelo servidor, isso resulta em uma linha duplicada, porque o ID inventado pelo cliente nunca foi usado.
Uma chave identifica uma requisição: ela registra o método, o caminho, a query
string e o corpo para os quais foi reivindicada. Reenvie essa requisição exata e sua
resposta será reproduzida; envie uma diferente sob a mesma chave e ela será recusada
com IDEMPOTENCY_KEY_REUSED (422) em vez de ser respondida com o resultado da
primeira. Uma nova tentativa que chegue enquanto a primeira ainda estiver em
andamento recebe IDEMPOTENCY_KEY_IN_PROGRESS (409) — envie-a novamente assim que a
primeira for concluída. A query string conta porque muda o que uma requisição faz:
DELETE /api/data/posts/5?hard=true sob a chave de uma exclusão lógica anterior é
uma requisição diferente, não uma repetição. A ordem dos seus parâmetros não
importa.
Suportado em POST, PATCH, DELETE, em todas as três rotas /bulk e em /_batch.
As chaves duram 24 horas e têm escopo restrito ao chamador autenticado; uma requisição não
autenticada não possui um principal ao qual associar o escopo, portanto o cabeçalho é ignorado
nesse caso. Um backend incapaz de armazenar chaves ignora o cabeçalho em vez de recusar a escrita.
DELETE é o caso que vale a pena ler duas vezes. Ao ser repetida sem uma chave, a segunda
tentativa descobre que a linha não existe mais e responde 404 — o que um cliente que está
tentando novamente interpreta como uma falha permanente para uma exclusão que, na verdade,
foi bem-sucedida. Com uma chave, a resposta 204 é reproduzida.
Concorrência otimista: ETag e If-Match
Seção intitulada “Concorrência otimista: ETag e If-Match”GET /api/data/:slug/:id retorna uma ETag, assim como o GET aninhado de uma
única linha. Envie-a de volta como If-Match em um PATCH ou DELETE posterior e
a escrita será recusada com 412 se a linha tiver sido alterada nesse intervalo.
Um PATCH bem-sucedido responde com a ETag da versão que escreveu,
de modo que a próxima escrita condicional não precisa de uma leitura antes. A tag
viaja também em um 204 respondido para Prefer: return=minimal, e uma resposta
reproduzida para uma Idempotency-Key carrega a mesma tag da primeira. Uma
subscrição ativa de linha carrega a mesma tag em cada frame (veja Tempo real).
# readcurl -i /api/data/docs/d1# → ETag: "9f2c…"
# write, conditionallycurl -X PATCH /api/data/docs/d1 \ -H 'If-Match: "9f2c…"' \ -d '{"title": "Second draft"}'# → 412 PRECONDITION_FAILED if somebody else edited it firstSem isso, o ciclo leitura-modificação-escrita adota a regra do “último a escrever vence” sobre tudo o que a segunda escrita não enviou: dois editores com um segundo de diferença têm sucesso e a alteração do primeiro desaparece sem nenhum erro em lugar algum.
A tag é derivada de uma propriedade date com autoValue: "on_update" quando a coleção
declara uma — essa coluna já é uma versão — e a partir de um hash estável da linha caso
contrário. If-Match: * afirma apenas que a linha existe. Nada é escrito quando a pré-condição
falha.
O formulário de registro do admin salva com If-Match: um salvamento feito contra uma versão
que alguém já substituiu é recusado, o formulário informa isso, e a edição permanece
no formulário, reaplicada sobre a nova versão com os campos em conflito marcados.
A tag é comparada dentro da própria transação da escrita, depois que a linha é bloqueada.
Duas escritas com a mesma tag não podem passar ambas: a segunda espera a primeira
ser confirmada, lê o que ela escreveu e é recusada com 412.
Operações de campo
Seção intitulada “Operações de campo”O valor de uma propriedade no corpo de um PATCH pode ser uma operação sobre o valor
armazenado, em vez de um valor:
curl -X PATCH /api/data/posts/p1 -d '{ "views": { "$inc": 1 }, "tags": { "$push": "featured" }, "meta": { "$merge": { "seen": true } }}'| Operador | Tipo de propriedade | Torna-se |
|---|---|---|
$inc |
number |
SET col = COALESCE(col, 0) + n |
$push |
array |
array_append(col, …), ou uma concatenação jsonb |
$pull |
array |
array_remove(col, …), ou uma reagregação jsonb |
$merge |
map |
`col |
A vantagem é a leitura que o chamador não precisa mais fazer. Expressar views + 1 como um
valor significa lê-lo primeiro, e duas requisições que leem 4, somam um e escrevem 5
terminam em 5 — sem nada em nenhuma das respostas indicando que um incremento foi perdido.
Compilada diretamente na instrução, a operação aritmética ocorre dentro do bloqueio da linha
e não se perde.
Exatamente um operador por campo. Um operador em um tipo de propriedade para o qual não foi
definido, um $operator desconhecido ou um operando com formato incorreto resulta em um 400
(INVALID_FIELD_OPERATION) indicando o nome do campo — um erro de digitação nunca é gravado
na coluna como um documento JSON.
Um map é a exceção, porque seu valor é um documento JSON e pode trazer chaves $ próprias — o
$schema e o $id de um JSON Schema, uma exportação do MongoDB. Em um map, só um objeto com exatamente
uma chave que seja um dos operadores acima é uma operação; qualquer outro objeto é armazenado como foi
enviado, chaves $ incluídas. Assim, { "meta": { "$merge": { … } } } mescla, e
{ "meta": { "$schema": "https://json-schema.org/draft/2020-12/schema" } } é armazenado. Um operador com
erro de digitação em um map também é, portanto, armazenado; em qualquer outro tipo continua sendo recusado.
O cliente offline não tem schema para distinguir um map de qualquer outra coisa, então, enquanto está
offline, ele recusa esse valor como uma operação de campo; envie-o quando a conexão voltar.
As operações se aplicam apenas a atualizações: em uma linha
que ainda não existe não há nada sobre o que operar, portanto elas são recusadas em POST,
em criações via /bulk e em upserts.
Uma operação responde à validation da propriedade como um valor. Os elementos adicionados são julgados pelas regras do elemento, as chaves mescladas pelas propriedades do mapa e o valor que uma operação produz pelos limites declarados: min, max, moreThan, lessThan, positive e negative de um número, e a quantidade mínima e máxima de itens (min, max) de um array. Esta última verificação é uma condição do mesmo UPDATE, não uma leitura prévia. Em stock: { validation: { min: 0 } }, { "stock": { "$inc": -1 } } tem sucesso enquanto o estoque for pelo menos 1 e é recusado em 0 com o mesmo 400 (VALIDATION_CONSTRAINT) que { "stock": -1 } recebe, indicando o campo e o limite. Dois decrementos concorrentes de um estoque de 1 não podem ambos ter sucesso, porque o segundo é julgado contra a linha que o primeiro confirmou. Um número não definido conta como 0 e um array não definido como vazio. Uma operação recusada não grava nada, nem mesmo os valores simples ao lado dela no corpo. Como a verificação faz parte da instrução, ela também vale para escritas internas ao processo via rebase.data, que ignoram a validação no nível da requisição.
Upsert em uma chave natural
Seção intitulada “Upsert em uma chave natural”POST /api/data/:slug?on_conflict=sku insere a linha, ou atualiza a que
já contém esse SKU, em vez de falhar por causa dela. A rota em lote
aceita o mesmo destino que onConflict junto a upsert: true, assim como cada operação de
upsert de um lote.
curl -X POST '/api/data/products?on_conflict=sku' \ -d '{"sku": "LAMP-01", "name": "Desk lamp", "price": 39}'
curl -X POST /api/data/products/bulk -d '{ "rows": [ … ], "upsert": true, "onConflict": ["tenant_id", "sku"]}'O destino deve possuir uma garantia de unicidade com a qual o banco de dados possa fazer a
correspondência: a chave primária (o padrão quando nenhuma é especificada), uma propriedade
com validation: { unique: true } ou as colunas de um índice com
unique: true. Qualquer outra coisa resulta em um 400 (INVALID_CONFLICT_TARGET) listando
os destinos que realmente existem — caso contrário, o Postgres responderia there is no
unique or exclusion constraint matching the ON CONFLICT specification de dentro de uma
transação que já realizou trabalho.
Especificar um destino sem upsert: true em uma escrita em lote também resulta em 400:
ignorá-lo silenciosamente transformaria uma importação que pode ser executada novamente em
uma que duplica dados.
?on_conflict= é recusado em uma criação aninhada (INVALID_CONFLICT_TARGET). A
linha que ele encontrasse poderia estar sob outro pai, e o upsert a moveria para
este. Envie o upsert para a rota própria da coleção.
Um upsert que encontra uma linha armazenada é uma atualização dela. O servidor lê a linha
que a chave indica, no escopo do chamador e dentro da transação da escrita, e
executa a atualização comum: os campos do corpo são gravados, os que ele omite
mantêm seus valores armazenados em vez de serem redefinidos para seu defaultValue,
beforeSave e afterSave veem status: "existing" com os valores anteriores,
e o histórico registra uma atualização. Uma linha que a leitura não conseguiu ver, ou uma inserida
concorrentemente, ainda é capturada por ON CONFLICT … DO UPDATE, que define apenas o que
o corpo e os hooks escreveram, além dos carimbos on_update. Uma chave que indica uma linha
fora do escopo beforeQuery do chamador responde 404. Uma chave que pertence a
uma linha na lixeira responde 409 ROW_IN_TRASH
e nada é escrito: restaure a linha primeiro, ou a expurgue.
Uma linha que já existia mantém seu timestamp on_create e seu criador
user_on_create. Um conflito significa que a criação da linha é um fato do
passado, e uma reimportação noturna que redefinisse createdAt em tudo o que tocasse
arruinaria qualquer consulta de “novidades desta semana”.
Não na coleção de usuários
Seção intitulada “Não na coleção de usuários”A coleção de usuários (a que tem auth ativado) recusa um upsert por qualquer porta — ?on_conflict=, /bulk com upsert: true, uma operação upsert de _batch, o upsert() do SDK e o socket em tempo real — com um 400 (INVALID_CONFLICT_TARGET). Suas linhas são os usuários, e criar um usuário e alterá-lo seguem regras diferentes: uma criação faz o hash da senha, normaliza o email, executa onCreateUser e envia o convite, e uma atualização não pode rebaixar o último administrador. Um upsert só descobre qual dos dois é dentro da escrita, tarde demais para aplicar qualquer um. Crie usuários com um POST simples e altere-os com PATCH.
Prefer: return=minimal
Seção intitulada “Prefer: return=minimal”Toda escrita responde com a linha completa por padrão, que é o que contém as coisas decididas
pelo servidor — um ID serial, um carimbo de autoValue ou o que quer que beforeSave tenha
reescrito. Envie Prefer: return=minimal quando você não precisar de nada disso:
curl -X POST /api/data/events \ -H "Prefer: return=minimal" \ -d '{"kind": "page_view"}'# → 204 No Content, Preference-Applied: return=minimalUma única escrita responde com 204. Uma escrita via /bulk ou /_batch responde com 200
contendo os ids em vez das linhas — em uma criação, o ID é a única coisa que o chamador
não consegue calcular, portanto descartá-lo significaria reler a tabela por alguma chave
natural para saber o que acabou de ser gravado. Uma chave de coluna única retorna como um
escalar; uma chave composta, como um objeto com suas colunas.
Lotes entre coleções
Seção intitulada “Lotes entre coleções”POST /api/data/_batch realiza escritas em várias coleções em uma única transação, sob a
própria role do chamador, com a mesma validação, callbacks e segurança a nível de linha que a
rota de linha única de cada operação aplicaria.
POST /api/data/_batch{ "operations": [ { "op": "create", "collection": "orders", "values": { "total": 40 }, "ref": "order" }, { "op": "create", "collection": "order_items", "values": { "order_id": { "$ref": "order.id" }, "sku": "A-1" } }, { "op": "update", "collection": "stock", "id": "A-1", "values": { "count": { "$inc": -1 } } }, { "op": "delete", "collection": "carts", "id": "c-9" } ]}{ "data": [ { "id": 31, "total": 40 }, { "id": 88, … }, { … }, null ], "meta": { "operations": 4 }}op pode ser create, update, upsert ou delete. update e delete precisam de um
id; create, update e upsert precisam de values; upsert pode especificar um destino
onConflict nos mesmos termos descritos acima. data é alinhado com operations — a linha
escrita para create, update ou upsert, e null para delete —, portanto um índice em um
corresponde ao índice no outro.
$ref: apontando para uma linha criada pelo mesmo lote
Seção intitulada “$ref: apontando para uma linha criada pelo mesmo lote”Uma operação pode se identificar com ref, e qualquer operação posterior pode colocar
{ "$ref": "<name>.<field>" } onde iria um valor — em values em qualquer nível de
profundidade, ou como um id. Isso é resolvido para o campo correspondente da linha que a
operação nomeada gravou.
Esta é a razão pela qual o endpoint existe em vez de ser um loop: a chave estrangeira de um registro filho não pode ser conhecida até que o pai seja inserido, então, sem isso, o pai e os filhos teriam que ser requisições separadas — exatamente a sequência que pode ter um sucesso parcial. Apenas referências para trás são resolvidas; referências para a frente são recusadas antes da abertura da transação.
O que é verificado antes de qualquer escrita
Seção intitulada “O que é verificado antes de qualquer escrita”Formato, coleções desconhecidas, campos desconhecidos, restrições de valor, operações de campo,
destinos de conflito e a acessibilidade de $ref são todos validados antes da abertura da
transação. Um lote é tudo-ou-nada, e encontrar um erro de digitação na operação 40, de outra
forma, custaria o rollback das 39 operações anteriores.
Limitado ao mesmo número de operações de uma escrita em lote (1000 por padrão), já que um lote
retém seus bloqueios durante toda a transação. Os callbacks de cada operação
rodam dentro dela, um após o outro, de modo que um hook lento é pago uma vez por linha
com a transação aberta: 100 linhas cujo beforeSave aguarda 50 ms seguram uma conexão,
e os bloqueios de todas as linhas escritas até então, por 5,3 s. O mesmo vale para uma
escrita em /bulk. Veja Um callback segura uma conexão. Um driver incapaz de tornar o lote atômico
responde com BATCH_UNSUPPORTED em vez de recorrer a um loop de escritas individuais — o que
não ofereceria nem a atomicidade nem o round trip único buscados ao usar um lote.
Um update ou delete especificando uma linha que não existe faz todo o lote falhar com 404,
pelo mesmo motivo que uma escrita parcial é recusada em qualquer outro lugar: um estado
parcialmente aplicado não oferece uma recuperação adequada.