Formato do pacote (v2)
Um pacote de template tem duas metades, guardadas em sítios diferentes:
- o manifesto, um objeto JSON na coluna
manifestda tabelaTemplate; - as linhas, uma entrada por tabela na tabela
TemplatePayload.
A separação não é cosmética. As streams de Template do PowerSync são
SELECT *, logo o manifesto de um template system desce para todos os
dispositivos de todos os clientes. A TemplatePayload não entra em nenhuma sync
rule: lê-se e escreve-se por HTTP, com a RLS espelhada do Template pai. Guardar
as linhas dentro do manifesto punha dados de um workspace a descer para quem nunca
os pediu.
A versão 2 acrescenta source, items[], dependencies[], unresolved[] e
redacted[] ao manifesto que já existia, e continua a ler a versão 1 sem
conversão: a ausência de templateVersion lê-se como "1".
O ficheiro: o envelope {manifest, payloads}
Fora da base, as duas metades viajam juntas num só ficheiro .json: o objeto de
topo tem o manifest e o array payloads, uma entrada por item rows — e cada
entrada é a entrada do exportador, o mesmo objeto que fica na coluna rows
da TemplatePayload (ver As linhas) e não a linha
da tabela. É este envelope que o botão Importar pacote (.json)… lê e o que o
infra/scripts/dump_template_package.sh escreve. O payloads pode faltar (lê-se
como vazio) e um manifesto solto no topo, sem envelope, também se aceita; um
ficheiro sem templateVersion não é um pacote e segue como plano.
{
"manifest": {
"templateVersion": "2",
"id": "frota",
"name": "frota",
"label": "Frota",
"scope": "workspace",
"semver": "1.0.0",
"items": [{ "uid": "u_0009", "kind": "rows", "count": 1 }]
},
"payloads": [
{
"featureRef": "f_viaturas",
"itemUid": "u_0009",
"count": 1,
"rows": [{ "uid": "u_r001", "data": { "matricula": "AA-00-AA" } }]
}
]
}
As duas camadas do manifesto
O manifesto é a união de duas camadas que ninguém mistura:
- a identidade de catálogo —
id,name,label,description,icon,color,vocabulary,category,scope,variables[],blocks[],tags[],semver,visibility— escrita por quem publica o template; - o pacote —
templateVersion,source,items[],dependencies[],unresolved[],redacted[],plan— escrito pelo exportador.
scope é um de organization, workspace, module, feature; visibility é
um de system, org, public.
{
"templateVersion": "2",
"id": "erp-oficina",
"name": "erp_oficina",
"label": "ERP de oficina",
"scope": "workspace",
"visibility": "org",
"semver": "1.0.0",
"variables": [{ "key": "nome_oficina", "label": "Nome da oficina" }],
"source": {
"workspaceName": "Oficina",
"generatedAt": "2026-09-12T18:02:00Z",
"definitionsVersion": "2026.09.1"
},
"items": [
{ "uid": "u_0001", "kind": "feature", "ref": "f_viaturas", "label": "Viaturas" },
{ "uid": "u_0002", "kind": "column", "ref": "c_viaturas_matricula",
"label": "Matrícula", "parent": "u_0001" },
{ "uid": "u_0003", "kind": "column", "ref": "c_viaturas_fornecedor",
"label": "Fornecedor", "parent": "u_0001" },
{ "uid": "u_0005", "kind": "view", "ref": "v_viaturas_documentos",
"label": "Documentos", "parent": "u_0001" },
{ "uid": "u_0007", "kind": "workflow", "ref": "w_ocr_compras",
"label": "OCR de compras", "stepIds": ["s_ler", "s_resolve"] },
{ "uid": "u_0009", "kind": "rows", "parent": "u_0001", "count": 128,
"dataClass": "transactional" }
],
"dependencies": [
{
"uid": "u_0011",
"kind": "feature",
"ref": "f_fornecedores",
"form": "uuid",
"required": true,
"resolution": "import",
"detail": "blueprint",
"description": "Tabela de onde se escolhem as linhas.",
"identity": {
"name": "fornecedores",
"label": "Fornecedores",
"columns": [
{ "uid": "u_0012", "ref": "c_fornecedores_nif", "name": "nif",
"label": "NIF", "type": "text", "defaultComponent": "textfield" }
]
},
"usedBy": [
{ "uid": "u_0003", "targets": "u_0011", "path": "linkedFeatureId",
"reason": "Tabela de onde se escolhem as linhas." }
],
"blueprint": { "steps": [] }
}
],
"unresolved": [
{ "uid": "u_0005", "path": "[0].workflowV2Id", "kind": "workflowV2",
"form": "uuid", "sourceLabel": "OCR de compras", "reason": "ref_adiante",
"action": "chave esvaziada" }
],
"redacted": [
{ "uid": "u_0007", "path": "connectionVariables",
"reason": "campo declarado `secret`: pode transportar segredos do inquilino de origem, logo sai redigido" }
],
"plan": {
"planVersion": "1",
"id": "u_0000",
"summary": "Estrutura do workspace \"Oficina\" (1 tabela, 1 coluna, 1 automação, linhas de 1 tabela)",
"target": { "workspaceId": "$workspace" },
"metadata": { "source": "export", "generatedAt": "2026-09-12T18:02:00Z" },
"externalRefs": ["f_fornecedores", "c_fornecedores_nif"],
"steps": []
}
}
O source tem três campos e mais nada: o nome do workspace de origem, o
instante da exportação e a versão das declarações com que o pacote foi escrito.
Nenhum identificador de organização ou de workspace entra aqui.
items[] — o que vai em cheio
Cada item tem um uid estável (um u_ seguido de um UUID v4) que é a chave por
que o unresolved[], o redacted[], os steps do plano e as entradas de
TemplatePayload lhe apontam.
| Chave | O que diz |
|---|---|
uid | A identidade do item dentro do pacote. |
kind | Um de feature, column, view, module, rail, workflow, rows. |
ref | O $ref que o plano declara para ele. Ausente exatamente quando o kind é rows. |
label | O rótulo de origem, para quem lê o pacote. |
parent | O uid do item pai quando o pai também viaja: a tabela de uma coluna ou de uma vista, o módulo de um rail, a tabela de um rows. |
count | Só em rows: quantas linhas a entrada de payload leva. |
dataClass | Só em rows: reference ou transactional. |
stepIds | Só em workflow: os id dos passos do grafo, que ficam estáveis. |
O kind de um item não se lê na letra do ref: a gramática de referências
partilha o prefixo w_ entre workspace e workflow, e é por isso que cada item
leva o seu kind.
dataClass é derivado e nunca perguntado: uma tabela que alguma coluna de
ligação do pacote (linked_select, auto_match) de outra tabela aponta é
tabela de domínio (reference), e as linhas dela são a proposta natural; uma
tabela que nenhuma ligação dessas aponta é transactional, e as linhas dela são
dados de um cliente — uma vista, um separador de rail ou um gatilho de automação
dizem onde a tabela aparece e quem lhe mexe, não que as linhas de outra apontem
as dela. O exportador escreve sempre um dos dois — tem o fecho, logo tem
sempre resposta — e não diz por item de onde tirou a classe nem onde estão as
linhas: a classe é derivada e as linhas estão sempre na
TemplatePayload. Quem lê trata qualquer outro valor (um dataClass escrito
à mão ou vindo de uma versão anterior do formato) como não sendo reference, que
é a única classe que muda o que o ecrã de seleção propõe.
stepIds existe para uma razão concreta: os tokens {{vars.steps.<id>.…}}
dentro de um grafo apontam para esses id e não se regeneram na importação.
dependencies[] — o que vai descrito
Uma dependência é uma entidade que o pacote usa e não leva. Um nível só, e
uma entrada por entidade-raiz: uma coluna nunca é dependência, a tabela dela é
— com a coluna em identity.columns. Cinco referências à mesma tabela dão uma
dependência, não cinco.
| Chave | O que diz |
|---|---|
uid | A identidade da dependência dentro do pacote. |
kind | O tipo de entidade, no vocabulário das referências: feature, view, module, rail, workflow, workflowV2, connection, timerProfile, channel. Nunca column. |
ref | O $ref simbólico pelo qual as configs do pacote a nomeiam. |
form | uuid ou name, e é a forma efetiva e não a declarada: um campo que aceita id ou nome conta como uuid se casou por id e como name se casou por nome. |
required | false só quando todos os usos estão em campos não obrigatórios e com valor por omissão. |
resolution | A proposta do exportador: import, map ou skip. |
detail | blueprint quando há blueprint, identity quando não. |
description | A frase que o assistente de importação mostra a quem decide. |
identity | Como se reconhece a entidade no destino: name, label e as colunas usadas. |
usedBy[] | Cada uso: que item a usa, em que caminho, e porquê. |
blueprint | Os steps que a reconstroem. Só para kind: feature. |
A form decide o que o destino pode fazer: uma dependência de forma name
pode ser satisfeita por um nome igual, uma de forma uuid não. Numa dependência de
forma name a config mantém o nome em vez de um $ref — o ref existe para a
dependência se poder nomear em externalRefs e em usedBy. É o caso de
connection e de timerProfile, cujos refs levam o próprio tipo por prefixo
(connection_<nome>, timerprofile_<nome>) porque a gramática de refs só cobre as
letras f, c, v, r, m e w. Um channel é de forma uuid e a config
leva mesmo o $ref, logo tem de bater a gramática: o ref dele é m_<nome>, pela
mesma regra do w_, que já serve workspace e workflow — o tipo lê-se no kind e
nunca na letra. Atenção à
assimetria de vocabulário: um item de automação tem kind: workflow, mas uma
dependência de automação pode ter kind: workflowV2 — o item descreve o que
viaja, a dependência descreve a referência que a apontou.
A resolution é uma proposta e nunca uma decisão; quem decide é o destino.
A regra é fechada:
mapquando okindnunca se reconstrói —connection,mailbox,channel,timerProfilelevam segredos ou estado do inquilino;mapquando aforméname— o nome é o que o destino sabe procurar;importquando háblueprint,mapquando não há.
O exportador nunca propõe skip: é uma escolha de quem importa. E skip
deixa a chave de referência vazia, nunca com o uuid de origem, e carimba o item
no relatório final como quebrado de propósito. Um uuid morto seria uma coluna que
existe, não liga e não se queixa. Quem esvazia é o executor, no momento de
aplicar: reescreve o plano e as linhas antes do primeiro step, e cada campo que
ficou vazio sai no relatório do job com a razão dependencia_saltada e o ref
da dependência que o causou.
Uma dependência de canal (kind: channel) nasce de um passo notify com
scopeType: channel: viaja com a identity do canal de origem (name, e
label quando o há — nunca os membros), sem blueprint, e sempre
required: true — a omissão vazia do scopeId não é recurso nenhum, e um aviso
sem canal não se publica. O map oferece os canais com nome da organização
de destino (uma conversa direta não tem nome e não se mapeia); o skip deixa o
scopeId vazio, sai no relatório com dependencia_saltada, e a automação chega
ao destino por publicar — quem a quiser ativa escolhe o canal lá e publica-a.
A identity leva name, label e só as colunas efetivamente
referenciadas, cada uma com uid, ref, name, label, type e
defaultComponent. Mandar a tabela toda era mandar o esquema de uma organização
para dentro de um pacote que vai para outra.
O usedBy[] tem uid (o item que usa), targets (o uid da própria
dependência), path (o caminho dentro da config desse item) e reason — que é a
descrição declarada do campo que a referência ocupa. A description da dependência
é a reason do uso com mais peso: primeiro os usos de forma uuid, depois a ordem
de aparição. Gera-se sempre, nunca se escreve à mão.
O blueprint só existe para kind: feature: as vistas e as automações de
uma dependência não viajam. Leva um create_feature e um create_column por
coluna da identity, com os refs já cunhados, e a order é a da criação
(1, 2, 3, …) e não a da tabela de origem — o blueprint cria só as colunas
referenciadas, logo prometer a ordem da origem era mentir.
{
"steps": [
{ "uid": "u_0011", "ref": "f_fornecedores", "tool": "create_feature",
"args": { "name": "fornecedores", "label": "Fornecedores" } },
{ "uid": "u_0012", "ref": "c_fornecedores_nif", "tool": "create_column",
"args": { "featureId": "$f_fornecedores", "name": "nif", "label": "NIF",
"type": "text", "defaultComponent": "textfield", "order": 1 } }
]
}
O detail é derivado e gravado à mesma, porque é o que um leitor humano vê
primeiro. Quem constrói um pacote tem de o manter coerente: detail é
blueprint se e só se blueprint existir, e um pacote em que os dois discordem é
recusado na leitura em vez de corrigido em silêncio.
As resoluções, no pedido de aplicação
Quem aplica um pacote decide, dependência a dependência, o que acontece no
destino. A decisão viaja no corpo do pedido (POST /apply/templates/{id} no
executor, POST /v1/templates/{id}/apply na API, apply_template no MCP), num
mapa indexado pelo $ref da dependência — o cifrão é tolerado, porque quem
escreve o mapa à mão copia-o do plano:
{
"workspaceId": "…",
"includeRows": true,
"resolutions": {
"f_clientes": {"action": "map", "id": "<id da tabela no destino>",
"columns": {"c_clientes_nif": "<id da coluna no destino>"}},
"f_artigos": {"action": "import"},
"f_zonas": {"action": "skip"}
}
}
| Ação | O que faz |
|---|---|
import | Aplica o blueprint da dependência: ela passa a ser criada pelo próprio plano. Sem blueprint é recusado, com o nome do ref. |
map | Liga o $ref a uma entidade que já existe no destino. O id é obrigatório. |
skip | Esvazia tudo o que a dependência segurava e relata cada campo. Nada fica a apontar para um id da organização de origem. |
O columns é opcional e só faz sentido dentro de um map: mapeia, uma a
uma, as colunas da identity da dependência (<ref da coluna> para o id dela no
destino). As que ficarem de fora continuam por ligar — mapear a tabela não
adivinha as colunas.
Uma coluna por ligar que o plano use é recusada antes de o job nascer, com
422 mapping_columns_incomplete e o ref da coluna na resposta: o plano
pedia um $c_* que ninguém semeou e isso rebentava lá dentro da transação da
estrutura, sem dizer a quem chamou o que corrigir. Uma coluna da identity que o
plano não use não é recusada — não falta a ninguém.
Um id mapeado tem de ser uma tabela do workspace de destino ou uma shared da
organização dele, e a coluna tem de ser da tabela que a mesma resolução
mapeou. É a mesma fronteira que as sugestões oferecem: aceitar aqui o que o
assistente nunca propõe deixava mapear para fora do destino. As shared entram
porque vivem noutro workspace da mesma organização por desenho. Um id fora daí é
recusado com mapping_feature_not_found ou mapping_column_not_found, os dois
com o ref na resposta — uma coluna da organização certa mas de outra tabela era
uma ligação plantada no sítio errado.
O skip esvazia três coisas, pela regra do exportador (escalar vira "",
chave de mapa desaparece): as ocorrências do $ref da dependência nos args
dos steps; as ocorrências dos $ref das colunas da identity dela; e as
células das linhas das colunas de ligação que a apontavam — que não têm $ref
nenhum, porque o exportador guarda a célula com o valor de origem. Nenhum step se
larga: largar um fazia desaparecer o rail inteiro.
Os modos de linhas, no mesmo pedido
Ao lado das resoluções, e não dentro delas: as resoluções respondem às
dependências e os modos às linhas, e misturá-los dava um vocabulário com duas
gramáticas. O mapa é indexado pelo featureRef da tabela (o cifrão é tolerado):
{
"includeRows": true,
"rowsModes": {"f_artigos": "missingByKey", "f_movimentos": "none"}
}
| Modo | O que faz |
|---|---|
all | Insere todas as linhas do pacote. É a omissão: um featureRef que não esteja no mapa fica com ele. |
missingByKey | Insere só as linhas cuja chave ainda não existe no destino. Exige a chave verificada no pacote (keyColumn com keyUnique: true). |
none | Não insere linha nenhuma dessa tabela. |
O includeRows: false é o interruptor global e ganha sempre: com ele não se
lê modo nenhum. Um missingByKey numa tabela sem chave verificada é recusado com
422 rows_mode_without_key e o ref na resposta — adivinhar a chave
duplicava linhas do cliente em silêncio.
A comparação é a mesma que decidiu o keyUnique na origem: o valor da célula em
cru, sem trim e sem distinguir maiúsculas. Duas réguas para a mesma chave
davam "já existe" de um lado e "falta" do outro. As linhas deixadas de fora
contam-se no relatório do job, por tabela, em rows[<featureRef>].existing:
{"rows": {"f_artigos": {"inserted": 419, "updated": 0, "skipped": 0, "existing": 31}}}
O existing é do que o modo filtrou nesta passagem; o skipped continua a
ser o que uma passagem anterior já tinha escrito. A pré-visualização da app não
adianta este número: as linhas do destino podem nem estar sincronizadas no
dispositivo, e um número que às vezes está certo é pior do que nenhum.
Uma linha deixada de fora continua a ser o alvo das ligações: o refs de
outra tabela que a aponte pelo uid liga à linha que já está no destino, e
não ao uuid da organização de origem que a célula trazia.
A segunda passagem das ligações à própria tabela
Uma coluna que aponta outra linha da mesma tabela (uma tarefa-mãe, uma
categoria-pai) não se resolve no lote: o alvo entra no mesmo lote ou num a
seguir, e só aí tem id. A célula entra vazia e a tabela faz uma segunda
passagem quando o último lote dela fecha, a escrever só essas colunas. As
células ficam no ledger do job: uma retoma continua onde ficou, e reescrevê-las
dá o mesmo valor. As que continuarem sem alvo no fim ficam vazias, com a entrada
row_ref_dropped de sempre — e uma coluna multi-seleção resolve-se inteira
ou fica vazia, como no lote.
Quantas células ela ligou conta-se no relatório, por tabela, em
rows[<featureRef>].linked. A chave só aparece quando houve o que adiar:
{"rows": {"f_tarefas": {"inserted": 120, "updated": 0, "skipped": 0, "existing": 0, "linked": 4}}}
As três fases do executor
Aplicar é estrutura, depois linhas, depois publicar. A estrutura
corre numa transação só; as linhas, em lotes, cada um com o seu ledger; e só no
fim é que as automações com published são promovidas a versão ativa. A ordem
não é arranjo: uma automação publicada na fase 1 escuta as tabelas que a fase 2
enche, e o pacote chegava com as automações já corridas sobre as linhas de
exemplo.
As que esperam ficam no ledger, em pendingPublishes, e saem de lá na mesma
transação que as promove: uma retoma depois das linhas e antes da promoção
publica-as na mesma, e reaplicar o pacote não as publica outra vez. Quantas foram promovidas está no
relatório, em published — numa reaplicação é zero.
O job, o lease e o 409
Aplicar um pacote é um job (TemplateApplyJob): o pedido devolve 202 com
{jobId, status} e o progresso lê-se em GET /v1/template-jobs/{id}. Quem o
corre segura um lease — lease_owner e locked_until na linha — que vale
60 segundos e se renova a cada lote de linhas e, na fase da estrutura, pelo
menos a cada 20 segundos. Sessenta segundos e não cinco minutos porque o TTL é o
tempo que quem retoma espera depois de o executor morrer: um executor abatido a
meio deixava o job preso até à expiração, com os pedidos a responderem 202 e nada
a acontecer durante minutos.
O lease toma-se quando ninguém o tem, quando já expirou, ou quando é do próprio owner — um executor que volte ao mesmo job não pode ficar à espera de si mesmo. Um lease vivo de outro nunca se toma, e quem o mede é o relógio da base.
Enquanto ele está vivo, o pedido é 409 job_active — e isso vale também para
um pedido que traz jobId, que é uma retoma: dar-lhe 202 era prometer uma corrida
que morria no lease, em silêncio.
{"error": "job_active", "jobId": "…", "status": "rows", "lockedUntil": "…"}
O lockedUntil é o instante até ao qual o job está preso. Sai também no GET /v1/template-jobs/{id} da API (locked_until) e no detail do 409 dela: um
prazo no futuro é alguém a trabalhar no job; um prazo no passado, ou nenhum, é um
job livre para retomar com o job_id na mão.
O que o job guarda das linhas que inseriu
O ledger guarda, por tabela do pacote, o id de todas as linhas que a
aplicação inseriu. Ficam de fora as que o modo por chave deixou passar por já
estarem no destino, as repetidas de uma chave que o pacote traz duas vezes e as
de uma atualização — nenhuma dessas foi criada aqui, e desfazê-las era mexer em
dado que já lá estava. Quantas ficaram guardadas conta-se no relatório, por
tabela, em rows[<featureRef>].recorded, e é esse o número que se pode desfazer:
{"rows": {"f_tarefas": {"inserted": 30, "updated": 0, "skipped": 0, "existing": 0, "recorded": 30}}}
O recorded é o total guardado e o inserted é o desta passagem: numa
retoma são dois números diferentes, e por isso têm nomes diferentes. A chave só
aparece quando há ids guardados — um job aplicado antes de o executor os começar
a guardar não os pode reconstruir, e as ações que os usam recusam-no com nome em
vez de responderem zero.
unresolved[] — o que aponta para fora
Toda a entrada diz o que o exportador fez, não o que ele gostaria de fazer. Um step nunca se larga por uma referência que não resolve: a chave esvazia-se e o buraco fica nomeado aqui.
{
"uid": "u_0009",
"path": "rows[].data.fornecedor",
"kind": "rows",
"form": "uuid",
"reason": "tabela_alvo_sem_linhas",
"action": "valor mantido",
"count": 2
}
O path é o caminho dentro da config do item (linkedValueColumn,
views[0].featureId, rows[].data.fornecedor). O das células de linhas não leva
índice de propósito: a situação é da coluna e conta-se uma vez, por mais
linhas que a repitam — uma tabela de 5000 linhas dava 5000 entradas iguais e o
painel de fecho ficava ilegível. O count é opcional e diz quantas linhas a
entrada agregada cobre, para a agregação não apagar a grandeza; falta nas
entradas que não se agregam, que é o caso de toda a chave de config. O ref é
opcional e só o executor o escreve, nas entradas de dependencia_saltada: é o
$ref da dependência que deixou o campo vazio, e sem ele o relatório dizia que um
campo ficou vazio sem dizer por causa de quem. O required é opcional, só o
executor o escreve e só quando é true: diz que a dependência saltada era
obrigatória, que é o que separa um pacote incompleto de um pacote apenas por
preencher. O source é opcional e só o relatório do job o escreve, com o
valor package: marca as entradas que vieram do manifesto — o pacote já as
trazia — e distingue-as das que a aplicação produziu. O
sourceLabel é opcional e é o
rótulo da entidade na origem quando se conhece — nunca o id; falta quando o
alvo não se conhece. O kind é o tipo da referência, mais o valor rows para o
caso das células de linhas — que não é um tipo de referência. A form aqui é a
declarada, logo pode ser uuidOrName, ao contrário da de uma dependência.
O unresolved[] do relatório do job tem esta forma e nenhuma outra, venham as
entradas do pacote, da reescrita das dependências saltadas ou da fase das linhas:
duas gramáticas no mesmo campo obrigavam quem o lê a saber de qual das duas é cada
linha. As do pacote entram na criação do job, marcadas com source: package: sem
elas, o que o manifesto já trazia por resolver via-se na pré-visualização e sumia
do relatório final. As da fase das linhas levam o uid da entrada de linhas (ou a chave do
step que as trouxe), kind: rows, form: uuid, o path da coluna
(rows[].data.<coluna>) ou rows[] quando a situação é da tabela inteira, e o
count das linhas que a entrada cobre.
reason
| Valor | O que significa |
|---|---|
inexistente | O que a referência aponta já não existe na origem. |
fora_da_selecao | O alvo existe na origem mas ficou fora da seleção, e a chave não tem declaração que a transforme em dependência (os pais de um item e os dois defaults de navegação). |
nao_exportavel | O alvo existe mas não é entidade que um template possa levar: um ficheiro, uma caixa de correio, uma pessoa por id, ou um perfil de timer guardado por id. Um canal não entra aqui: viaja como dependência a mapear. |
envelope_indeterminado | Um token de coluna (um {{record.x}} num passo) cuja tabela não se determina estaticamente. |
ref_adiante | O alvo viaja, mas o step que o declara corre depois do step que o usa: um $ref ali era um erro de plano, não uma tradução. Só sobra para os tipos que não têm step de atualização no fim do plano. Numa tabela, numa coluna ou numa vista a chave viaja no update_feature/update_column/update_view. |
sem_ref_cunhado | O alvo foi classificado mas não ficou com referência no pacote. |
tabela_alvo_sem_linhas | A célula liga a uma linha de uma tabela que viaja, mas cujas linhas não foram escolhidas. |
dependencia_saltada | Quem importa resolveu a dependência como skip: o campo ficou vazio de propósito. Nasce na aplicação e não no exportador, e é a única que traz ref. |
row_ref_dropped | A célula ligava a uma linha que não entrou no destino (a tabela dela ficou em none, o lote que a trazia falhou, ou a tabela foi resolvida como map e a coluna de valor do destino não conhece aquele valor): a célula é esvaziada, porque o que lá estava era o valor da linha na organização de origem. Nasce na aplicação. Uma ligação à própria tabela não conta aqui enquanto a tabela não fechar: essa fica para a segunda passagem, e só sai nomeada se nem aí tiver alvo. |
feature_ref_unresolved | Ninguém ligou o $ref da tabela: as linhas dessa unidade não entraram. Nasce na aplicação. |
payload_cycle | Duas entradas de linhas apontam-se uma à outra: entram pela ordem de entrada e a célula que não resolver fica vazia, com a entrada row_ref_dropped dela. Nasce na aplicação. |
action
| Valor | O que significa |
|---|---|
chave esvaziada | A chave ficou com "". É a omissão, e é o que acontece a tudo o que se reescreve — ou seja, ao que a declaração identifica por uuid. |
valor mantido | O valor ficou intacto. É o caso de um nome (e de um token, que é um nome dentro de texto livre): reescrevê-lo partia o que continua válido no destino. |
chave omitida | A chave nem saiu no step. É o caso dos dois defaults de navegação, defaultRailId e defaultModuleId. |
redacted[] — o que saiu redigido
Uma entrada por campo declarado secret cujo valor foi limpo, com uid, path
e reason. A reason é texto livre, para ler: o que importa é que o valor não
está no pacote. Um campo secret pode transportar um segredo da organização de
origem, logo nunca sai — mesmo que isso deixe o pacote incompleto.
plan — os steps
| Chave | O que diz |
|---|---|
planVersion | "1". |
id | A identidade do plano. |
summary | A frase do ecrã de revisão. Conta itens e não steps: um utilizador conta tabelas, não create_column. As linhas contam-se por tabela ("linhas de 2 tabelas"). |
target.workspaceId | Sempre $workspace. Um pacote aplica-se no workspace de destino, e o id do de origem não entra no pacote. |
metadata.source | export. |
externalRefs | Os refs que o plano usa e não declara: o da entidade-raiz de cada dependência e os das colunas dela. Não é igual ao conjunto das dependências, de propósito. |
steps[] | Os passos, pela ordem de aplicação. |
Cada step tem um uid próprio, e os que criam uma entidade referível têm também
ref — o $ref que os steps seguintes podem usar. Os update_module,
update_workspace, update_feature, update_column e update_view não
declaram ref nenhum: não criam nada.
{
"uid": "u_0002",
"ref": "c_viaturas_matricula",
"tool": "create_column",
"args": {
"featureId": "$f_viaturas",
"name": "matricula",
"label": "Matrícula",
"type": "text",
"defaultComponent": "textfield",
"order": 1
}
}
A ordem, e porquê
Os steps saem por ordem de dependência e não de conveniência:
create_featurecreate_column— as colunas referem featurescreate_view— as vistas referem features e colunascreate_workflow_v2— as automações referem tudo o que é dadoscreate_modulecreate_rail— os rails referem módulos e vistasupdate_moduleupdate_workspaceupdate_feature— a config da tabela, agora que as colunas e as automações existemupdate_column— a config da coluna, pela mesma razãoupdate_view— a config da vista, pela mesma razão
Os cinco update_* ficam no fim porque as chaves que levam apontam para coisas
criadas depois dos pais: a ordem dos rails e o rail por omissão de um módulo, a
ordem dos módulos e o módulo por omissão do workspace, a coluna que uma tabela
usa para notificar, a automação que o botão de uma coluna dispara, a automação
para onde uma vista de documentos encaminha os carregamentos.
Os três últimos só existem quando há chave adiantada a levar, e a config deles
substitui a inteira, nunca funde: quem exporta tem a config completa em mão,
e uma fusão profunda no destino era uma segunda semântica para o mesmo campo. A
do update_view viaja no mesmo envelope de um elemento que o create_view
escreve.
{
"uid": "u_0031",
"tool": "update_column",
"args": {
"columnId": "$c_pedido_criar_ordem",
"config": { "buttonLabel": "Criar ordem", "workflowV2Id": "$w_criar_ordem" }
}
}
{
"uid": "u_0032",
"tool": "update_view",
"args": {
"viewId": "$v_compras_documentos",
"config": [{ "workflowV2Id": "$w_ocr_compras" }]
}
}
Dentro do balde dos create_column a ordem é topológica, não a da origem:
uma relação entre duas tabelas (a coluna de A a apontar uma coluna de B) é a
referência mais comum do produto, e pela ordem de origem metade delas apontava para
a frente e saía esvaziada. Um ciclo genuíno — as mesmas duas colunas a apontarem
uma para a outra — não tem ordem que sirva as duas pontas: aí uma das pontas viaja
vazia no create_column e completa no update_column do fim do plano.
O que continua ref_adiante é o que não tem step de atualização que o leve:
qualquer referência adiantada de um item que não seja tabela, coluna ou vista.
As linhas não são steps. Não há insert_rows num plano exportado: as linhas
viajam no payload e aplicam-se numa segunda fase da importação.
As ferramentas que um plano pode nomear
create_workspace, create_module, create_rail, create_feature,
create_column, create_view, create_workflow_v2, insert_row, insert_rows,
update_row, bulk_update_rows, update_module, update_workspace,
update_feature, update_column, update_view. Um nome fora desta lista é
recusado como ferramenta desconhecida.
O create_workflow_v2 é a única que está documentada e que o cliente ainda não
executa. Os args são:
{
"uid": "u_0007",
"ref": "w_ocr_compras",
"tool": "create_workflow_v2",
"args": {
"workspaceId": "$workspace",
"name": "ocr_compras",
"label": "OCR de compras",
"triggerConfig": { "type": "record.event", "config": { "featureId": "$f_viaturas" } },
"graphConfig": { "steps": [] },
"isDraft": true,
"published": true
}
}
O gatilho vai à parte do grafo porque o featureId de um gatilho record.event
não vive no grafo. O isDraft é sempre true: a automação nasce como rascunho,
que nunca dispara. O published é opcional e só vai quando a automação de origem
tinha uma versão ativa — a promoção do rascunho fica para depois das linhas
(ver «As três fases do executor»), e a automação importada dispara como a
original. Sem ele, fica rascunho à espera de quem a publique. isActive nunca vai nos args: ativar é
promover o rascunho e não escrever uma coluna.
A auto-verificação
Antes de entregar o pacote, o exportador mede no plano montado as mesmas três regras que o validador aplica:
- nenhum
$refé usado antes de alguém o declarar — os builtins ($workspace) e osexternalRefscontam como declarados desde o primeiro step; - nenhum
$refé declarado duas vezes; - nenhum
$refusado fica sem declaração nem entrada emexternalRefs.
Um ref conta como "usado" quando uma string é, ela inteira, uma referência —
e as chaves de mapa contam, porque há chaves que são refs. Medir com outra régua
(um contains de $, por exemplo) aprovava planos que o validador recusa.
As linhas: TemplatePayload
Uma linha da tabela TemplatePayload por cada item de kind: rows, com
template_id, kind (hoje só rows), item_uid, feature_ref, row_count e
rows — um objeto jsonb, não um array. Há UNIQUE (template_id, item_uid):
reexportar o mesmo template faz upsert, não duplica. A tabela não tem
organization_id (o inquilino é o do Template pai) e não entra em nenhuma sync
rule.
O conteúdo da coluna rows:
{
"featureRef": "f_viaturas",
"itemUid": "u_0009",
"count": 2,
"keyColumn": "matricula",
"keyUnique": true,
"columns": ["matricula", "fornecedor"],
"rows": [
{
"uid": "u_r001",
"data": { "matricula": "AA-00-AA", "fornecedor": "cccccccc-0000-4000-8000-000000000404" },
"refs": { "fornecedor": { "rowUid": "u_r101", "featureRef": "f_fornecedores", "valueColumn": "id" } }
},
{
"uid": "u_r002",
"data": { "matricula": "BB-11-BB", "fornecedor": "ACME, Lda" }
}
]
}
As regras que a forma carrega:
-
o
dataé indexado por nome de coluna, como a linha de origem, e é sempre dado: nenhum token$entra lá. Um valor que já comece por$na origem fica como está — é o que impede"preco": "$100"de ser lido como uma referência; -
o
dataguarda sempre o valor de origem, inclusive quando hárefs: é o recurso de quem importa se a tabela ligada for mapeada ou saltada; -
a identidade da linha não é dado: a chave de
datacujo valor é oidda linha de origem — a colunaidautomática — sai dedata, sai decolumnse nunca ékeyColumn. Quem identifica a linha dentro do pacote é ouid, e os uuids da organização de origem não viajam; -
o sidecar
refsé opcional e existe por célula, não por linha. Só aparece quando a coluna é de ligação (linked_selectouauto_match), o valor casa com uma linha da tabela ligada, e as linhas dessa tabela também viajam. Cada entrada temrowUid(ouidda linha alvo dentro do pacote),featureRef(o$refda tabela dela) evalueColumn(o nome da coluna de valor no alvo); -
a célula de uma ligação não guarda a chave primária da linha alvo: guarda o valor da coluna que a
config.linkedValueColumnnomeia — a colunaidda tabela alvo, no caso normal, ou uma coluna de negócio como onipc. É por isso que a procura é pelo valor dessa coluna, com a chave primária como recurso (a célula de uma linha criada pela API leva o uuid formatado que a base cunha, e não a chave primária da linha), e é por isso que o destino escreve na célula o valor devalueColumnda linha alvo dele. Um sidecar semvalueColumné de um pacote anterior a esta regra e o destino escreve-lhe o id da linha, como antes; -
uma tabela que receba linhas e tenha coluna de identidade (
default_component: id) recebe a identidade do destino: a célula que venha vazia é cunhada à entrada da linha — um uuid ou o número seguinte da sequência, conforme oidTypeda coluna — e é esse valor, o que ficou escrito, que as células de ligação de outras tabelas guardam; -
uma coluna de ligação com
isMultiSelect— uma colunafornecedores, ao lado dafornecedordo exemplo — guarda uma lista de ids, e a entrada da célula passa a ser{ "rows": [ ... ] }com o mesmo parrowUid/featureRefpor elemento, pela ordem da célula. O que manda é a forma do valor e não a config, e os elementos que nenhuma linha alvo guarda — etiquetas, valores soltos — não entram e ficam só emdata:{
"uid": "u_r003",
"data": { "matricula": "CC-22-CC", "fornecedores": ["cccccccc-0000-4000-8000-000000000404", "ACME, Lda"] },
"refs": { "fornecedores": { "rows": [{ "rowUid": "u_r101", "featureRef": "f_fornecedores", "valueColumn": "id" }] } }
} -
se a tabela ligada viaja mas as linhas dela não, não há
refs: o valor fica emdatae a situação conta-se emunresolved[]com a razãotabela_alvo_sem_linhase a açãovalor mantido— uma entrada por coluna, e não por linha nem por elemento: mil linhas ligadas à mesma tabela são uma situação, não mil. A entrada leva ocountdas linhas que cobre. Quem aplica não escreve esse valor às cegas: se a tabela ligada for uma dependência resolvida comomap, o valor procura-se na coluna de identidade da tabela do destino — existe, fica como veio; não existe, a célula é esvaziada e a situação conta-se emrow_ref_dropped. Só as ligações pela coluna de identidade: o valor de uma coluna de negócio (um nome, umnipc) continua a valer no destino, e procurá-lo na identidade esvaziava-o; -
se a tabela ligada não tem
$refcunhado no pacote, também não hárefs: umfeatureRefnulo era uma ligação que o destino não sabia seguir. O valor fica emdatacom a açãovalor mantido, e a razão diz qual dos dois casos é —inexistentese a tabela já não existe na origem,sem_ref_cunhadose existe e ninguém lhe cunhou referência. Também aqui a entrada é uma por coluna; -
columnssão os nomes das colunas declaradas que alguma linha traz, pela ordem da tabela — é o que deixa o destino pré-visualizar sem adivinhar; -
keyColumné a coluna de texto por que o destino pode casar as linhas, ekeyUniqueé o resultado da verificação na origem. As candidatas ordenam-se pelo que a tabela declara sobre elas: primeiro a coluna obrigatória, depois a de texto livre marcada única na config, depois a que tem um nome de chave conhecido (code,codigo,ref,referencia,sku,nif,email,slug, sem distinguir maiúsculas), e só então qualquer coluna de texto, pela ordem da tabela. Em todas manda a verificação: os valores têm de ser todos preenchidos e distintos nas linhas exportadas, e uma candidata preferida que não cumpra cede à seguinte. Uma coluna de ligação nunca é candidata, em preferência nenhuma: a célula guarda o id ou o valor de outra tabela, que não identifica esta linha. Nula quando nenhuma serve: oferecer "acrescentar as que faltam" sem ter verificado a chave era duplicar linhas do cliente; -
o limite por entrada é 5000 linhas ou 4 MiB de JSON. Não é o exportador que recusa: quem recusa é o ecrã de seleção, que é quem pode oferecer escolher menos linhas.
Promoção a system
Um template só se promove a system — a visibilidade que desce para todos os
clientes — quando passa as cinco regras, todas medidas de uma vez para quem promove
saber tudo o que falta:
templateVersioné"1"ou"2", e declarado: um manifesto sem a chave não se promove, mesmo que a leitura lhe assuma"1";- o JSON inteiro não contém
"organization_id"nem"workspace_id"; - todo o item de
kind: rowstem a sua entrada emTemplatePayload; - o
countdo item é igual aorow_countdo payload; - nenhum payload sobra sem item
rowsque o declare.
O que nunca viaja
- Identificadores da origem. Toda a referência declarada vira
$refou fica vazia e nomeada emunresolved[]. Um uuid de outra organização dentro de um pacote é uma fuga, e um uuid morto numa config é pior: aplica-se sem se queixar. A única exceção é o valor de uma célula de ligação — ou a lista deles, num multi-select — que fica emdata: ali é dado e não referência, e o sidecarrefsé o que o torna resolúvel. Pela mesma régua, o valor da coluna de identidade de uma linha viaja quando é diferente da chave primária dela: é o valor que as células de ligação guardam, e não um ponteiro para a origem. - Identificadores de inquilino. Nem
organization_idnemworkspace_id, em circunstância nenhuma: a organização de destino deriva-se do workspace validado, do lado do servidor. Osourcedo pacote leva três campos e o nome do workspace de origem é um deles — o id não. - Segredos. Todo o campo declarado
secretsai limpo e nomeado emredacted[]. Ligações e caixas de correio viajam por nome ou entram nas dependências comresolution: map. - O
ide ofeature_idde uma linha. O payload copia odatacampo a campo e nunca a linha em bloco: copiar o mapa levava os dois identificadores da organização de origem sem ninguém notar. A identidade de uma linha dentro do pacote é ouidque o exportador lhe cunha. - O estado de uma automação.
isActivenunca vai num pacote.