Pular para o conteúdo

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.

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.

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.

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

# read
curl -i /api/data/docs/d1
# → ETag: "9f2c…"
# write, conditionally
curl -X PATCH /api/data/docs/d1 \
-H 'If-Match: "9f2c…"' \
-d '{"title": "Second draft"}'
# → 412 PRECONDITION_FAILED if somebody else edited it first

Sem 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.

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.

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”.

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.

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=minimal

Uma ú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.

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.

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.