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.
Desde 0.25 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.
Desde 0.25 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.
A tag é comparada dentro da própria transação da gravação, depois de a linha ser
bloqueada. Duas gravações com a mesma tag não podem passar ambas: a segunda espera
que a primeira faça commit, lê o que ela gravou 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”Since 0.25 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.