Saltar para o conteúdo principal

Formato do pacote (v2)

Um pacote de template tem duas metades, guardadas em sítios diferentes:

  • o manifesto, um objeto JSON na coluna manifest da tabela Template;
  • 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álogoid, name, label, description, icon, color, vocabulary, category, scope, variables[], blocks[], tags[], semver, visibility — escrita por quem publica o template;
  • o pacotetemplateVersion, 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.

ChaveO que diz
uidA identidade do item dentro do pacote.
kindUm de feature, column, view, module, rail, workflow, rows.
refO $ref que o plano declara para ele. Ausente exatamente quando o kind é rows.
labelO rótulo de origem, para quem lê o pacote.
parentO 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.
countSó em rows: quantas linhas a entrada de payload leva.
dataClassSó em rows: reference ou transactional.
stepIdsSó 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 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.

ChaveO que diz
uidA identidade da dependência dentro do pacote.
kindO tipo de entidade, no vocabulário das referências: feature, view, module, rail, workflow, workflowV2, connection, timerProfile, channel. Nunca column.
refO $ref simbólico pelo qual as configs do pacote a nomeiam.
formuuid 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.
requiredfalse só quando todos os usos estão em campos não obrigatórios e com valor por omissão.
resolutionA proposta do exportador: import, map ou skip.
detailblueprint quando há blueprint, identity quando não.
descriptionA frase que o assistente de importação mostra a quem decide.
identityComo se reconhece a entidade no destino: name, label e as colunas usadas.
usedBy[]Cada uso: que item a usa, em que caminho, e porquê.
blueprintOs 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:

  • map quando o kind nunca se reconstrói — connection, mailbox, channel, timerProfile levam segredos ou estado do inquilino;
  • map quando a form é name — o nome é o que o destino sabe procurar;
  • import quando há blueprint, map quando 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çãoO que faz
importAplica o blueprint da dependência: ela passa a ser criada pelo próprio plano. Sem blueprint é recusado, com o nome do ref.
mapLiga o $ref a uma entidade que já existe no destino. O id é obrigatório.
skipEsvazia 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"}
}
ModoO que faz
allInsere todas as linhas do pacote. É a omissão: um featureRef que não esteja no mapa fica com ele.
missingByKeyInsere só as linhas cuja chave ainda não existe no destino. Exige a chave verificada no pacote (keyColumn com keyUnique: true).
noneNã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 leaselease_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

ValorO que significa
inexistenteO que a referência aponta já não existe na origem.
fora_da_selecaoO 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_exportavelO 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_indeterminadoUm token de coluna (um {{record.x}} num passo) cuja tabela não se determina estaticamente.
ref_adianteO 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_cunhadoO alvo foi classificado mas não ficou com referência no pacote.
tabela_alvo_sem_linhasA célula liga a uma linha de uma tabela que viaja, mas cujas linhas não foram escolhidas.
dependencia_saltadaQuem 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_droppedA 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_unresolvedNinguém ligou o $ref da tabela: as linhas dessa unidade não entraram. Nasce na aplicação.
payload_cycleDuas 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

ValorO que significa
chave esvaziadaA 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 mantidoO 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 omitidaA 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

ChaveO que diz
planVersion"1".
idA identidade do plano.
summaryA 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.workspaceIdSempre $workspace. Um pacote aplica-se no workspace de destino, e o id do de origem não entra no pacote.
metadata.sourceexport.
externalRefsOs 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:

  1. create_feature
  2. create_column — as colunas referem features
  3. create_view — as vistas referem features e colunas
  4. create_workflow_v2 — as automações referem tudo o que é dados
  5. create_module
  6. create_rail — os rails referem módulos e vistas
  7. update_module
  8. update_workspace
  9. update_feature — a config da tabela, agora que as colunas e as automações existem
  10. update_column — a config da coluna, pela mesma razão
  11. update_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 os externalRefs contam como declarados desde o primeiro step;
  • nenhum $ref é declarado duas vezes;
  • nenhum $ref usado fica sem declaração nem entrada em externalRefs.

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 data guarda 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 data cujo valor é o id da linha de origem — a coluna id automática — sai de data, sai de columns e nunca é keyColumn. Quem identifica a linha dentro do pacote é o uid, 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_select ou auto_match), o valor casa com uma linha da tabela ligada, e as linhas dessa tabela também viajam. Cada entrada tem rowUid (o uid da linha alvo dentro do pacote), featureRef (o $ref da tabela dela) e valueColumn (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.linkedValueColumn nomeia — a coluna id da tabela alvo, no caso normal, ou uma coluna de negócio como o nipc. É 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 de valueColumn da linha alvo dele. Um sidecar sem valueColumn é 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 o idType da 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 coluna fornecedores, ao lado da fornecedor do exemplo — guarda uma lista de ids, e a entrada da célula passa a ser { "rows": [ ... ] } com o mesmo par rowUid/featureRef por 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ó em data:

    {
    "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 em data e a situação conta-se em unresolved[] com a razão tabela_alvo_sem_linhas e a ação valor mantidouma 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 o count das linhas que cobre. Quem aplica não escreve esse valor às cegas: se a tabela ligada for uma dependência resolvida como map, 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 em row_ref_dropped. Só as ligações pela coluna de identidade: o valor de uma coluna de negócio (um nome, um nipc) continua a valer no destino, e procurá-lo na identidade esvaziava-o;

  • se a tabela ligada não tem $ref cunhado no pacote, também não há refs: um featureRef nulo era uma ligação que o destino não sabia seguir. O valor fica em data com a ação valor mantido, e a razão diz qual dos dois casos é — inexistente se a tabela já não existe na origem, sem_ref_cunhado se existe e ninguém lhe cunhou referência. Também aqui a entrada é uma por coluna;

  • columns sã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, e keyUnique é 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:

  1. templateVersion é "1" ou "2", e declarado: um manifesto sem a chave não se promove, mesmo que a leitura lhe assuma "1";
  2. o JSON inteiro não contém "organization_id" nem "workspace_id";
  3. todo o item de kind: rows tem a sua entrada em TemplatePayload;
  4. o count do item é igual ao row_count do payload;
  5. nenhum payload sobra sem item rows que o declare.

O que nunca viaja

  • Identificadores da origem. Toda a referência declarada vira $ref ou fica vazia e nomeada em unresolved[]. 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 em data: ali é dado e não referência, e o sidecar refs é 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_id nem workspace_id, em circunstância nenhuma: a organização de destino deriva-se do workspace validado, do lado do servidor. O source do pacote leva três campos e o nome do workspace de origem é um deles — o id não.
  • Segredos. Todo o campo declarado secret sai limpo e nomeado em redacted[]. Ligações e caixas de correio viajam por nome ou entram nas dependências com resolution: map.
  • O id e o feature_id de uma linha. O payload copia o data campo 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 é o uid que o exportador lhe cunha.
  • O estado de uma automação. isActive nunca vai num pacote.