@microteia/console 2.10.0 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,55 @@
22
22
  - fix(command): what was broken, and what it did to whoever hit it.
23
23
  -->
24
24
 
25
+ #### [2.18.0] — 2026-08-28
26
+
27
+ - feat(console/functionGate): an extension's callable without `requireInstalledExtension` is refused, and a guard naming another extension's id is rejected the way a misgated trigger is. The refusal exists because the deploy makes every callable publicly invocable — this Console's own `callableAccess.ts` is there to confirm that grant went through — so the callable answers any signed-in user of the project, from any organization, whether or not they installed the extension, with the organization arriving in the payload.
28
+ - feat(console/functionGate): the scope of this check is the FILE, like the per-office loop and unlike the trigger guard. The guard runs inside the handler because it needs the `request`, and the authoring guide asks authors to keep the handler thin with the decision in a file beside it — demanding the call inside the parentheses would refuse exactly the code the guide asks for.
29
+ - feat(console/functionGate): the SHARED callable is left out, and it is recorded rather than silently skipped: `requireInstalledExtension` takes one id and a shared function serves several. The "any of the ones I serve" guard exists for a trigger (`forAnyInstalledExtension`), not for a callable.
30
+
31
+ #### [2.17.0] — 2026-08-28
32
+
33
+ - feat(console/customFunctions): `microteia.serves` in `functions/package.json` — function name to the extension ids it serves. It closes the widest and quietest gap in the whole withdrawal mechanism. A function in `functions/` has no owner by construction (no extension may claim it), so it sat outside the mechanism ENTIRELY: never recorded as applied, never classified as an orphan, never reported, and out of `--prune`'s reach. It stayed deployed forever, put up by every deploy, with every extension it served long uninstalled — and the authoring guide was the thing telling authors to put shared functions there.
34
+ - feat(console/provision): a shared function that declares who it serves is deployed while ANY of them is installed anywhere in the project, and becomes an orphan when none is. The question is asked against every active installation and not against the claims: an extension with no `manifest.backend` never becomes a claim, and could well be one of the served ones — deciding from the claim list would delete the function of somebody who has it installed. The applied record keeps the declared list rather than electing one owner, because that is what explains, months later, why the function was there and why it stopped being.
35
+ - feat(console/deploy): a shared function that declares who it serves leaves the unconditional deploy list. Putting it up here anyway would have `--prune` delete it and the next deploy restore it, indefinitely — the same loop the claim refusal avoids. One with NO declaration goes up exactly as before: keeping that is what stops this version from taking a function out of an installation that already works.
36
+ - feat(console/functionGate): a declared shared trigger without a guard is refused, and so is a guard naming an extension the function does not declare serving. The two lists answer the same question — `serves` decides the withdrawal, the guard decides the execution — and one of them being wrong makes the function act where it can no longer be withdrawn. Guarding by one of the declared extensions is accepted: the list is the ceiling, not the floor. A shared function with no `serves` is not asked for a guard, because without ids there would be no guard to ask for.
37
+ - feat(console/provision): shared functions with no `serves` are listed on every run. They keep working as before and nothing withdraws them — what changes is that they stop being invisible, which was the defect: whoever inherited an installation had no way to know those functions were outside the mechanism, and the only way to find out was a trigger firing years after the last extension that used it had gone.
38
+ - fix(console/provision): a parameter declared in `functions/` now marks the served shared functions as stale. They used to go up on every deploy, which masked the question; moving them to provisioning would have left one running with the old value, which is the defect that mechanism exists to close. The granularity is the directory, not the function, because `functions/` declares its `defineString` calls in one set without saying which function reads which.
39
+
40
+ #### [2.16.0] — 2026-08-28
41
+
42
+ - feat(console/functionGate): an extension's function whose event carries no `{tenantId}` — `onSchedule`, `onMessagePublished`, `onRequest`, the Storage object triggers, the login-blocking ones — is refused unless it loops with `forEachInstalledOffice`. The refusal only became honest once there was an answer to point at: before it, both of the author's options were wrong, and the worse one passed every check. A loop naming another extension's id is rejected the same way a misgated trigger is, and the report names WHICH guard got the wrong id, because the fix is in one string and the author should not have to guess which.
43
+ - feat(console/functionGate): the scope of this check is the FILE, not the call's parentheses. A document trigger's guard wraps that handler, so it has to be there; the loop runs inside the handler and may sit a couple of calls deeper, in a helper the author wrote. Demanding it in the parentheses would refuse correct code, and the price is stated: two scheduled functions in one file, one looping and one not, both pass.
44
+ - feat(console/functionGate): the list of constructors is explicit rather than "everything that is not a document trigger". RTDB is deliberately left out — its path HAS parameters, so a `{tenantId}` there is possible, and the platform uses no RTDB anywhere. What is not known is not refused.
45
+
46
+ #### [2.15.0] — 2026-08-28
47
+
48
+ - feat(console/backendResidue): provisioning writes what it found back to the database, at `backend_state/current`. It is the one place in the system that can say "this Cloud Function has no owner any more" — the answer needs a scan of every office in the project compared against the applied record — and it said so in the terminal and kept the conclusion in `microteia.yaml`, a file on one operator's machine. The application never learned anything, so the whole mechanism depended on somebody remembering. This is not a new path: the Console already writes to the database over REST, which is how `backendState` is stamped.
49
+ - feat(console/backendResidue): the document is written on EVERY run, empty included. Writing only when there are orphans would leave the banner up after `--prune` had already dealt with them, and a banner that never goes away is one people learn to ignore — the empty list is what clears the previous one. The list is the one left AFTER pruning: what it deleted is gone, what it could not delete stays, which is the state of the project now rather than the state at the start of the run. `tenants` and `extensions` travel with it, because "nobody needs it any more" is a claim about every office in the project and whoever reads the banner deserves to check it instead of believing it.
50
+ - fix(console/provision): failing to record the leftovers does not fail the command. The functions and indexes were already applied; what was left to do was a REPORT, and refusing the whole run would send whoever installs to repeat a command that finished its work. The failure is said out loud, though — staying quiet would leave a silent screen looking like "there is no residue", which is the opposite claim.
51
+ - feat(console/firestoreRest): `asInteger`, `asArray`, `asMap` and `patchFields`. The mask lists every field written, deliberately: without it a PATCH behaves differently depending on whether the document exists, and what is wanted here is convergence — since all fields are written every time, the complete mask is also what erases what stopped being true.
52
+
53
+ #### [2.14.0] — 2026-08-28
54
+
55
+ - feat(console/provision): claiming a function that lives in `functions/` — the directory for a function SEVERAL extensions share — is refused. `baselineClaimProblems` already guarded the same defect against the platform's own functions, and the sentence is identical: the deploy puts it up regardless, so the claim changes nothing while the extension is installed, and the day it is uninstalled `--prune` deletes a function other extensions depended on. This case adds one turn of the screw — the next deploy puts it back, because the source is still sitting in `functions/` — so the symptom is an outage that closes by itself, repeating on every `provision --prune`, with nothing in the tree to suggest what happened. Rejecting the extension puts the name in `unresolved`, which is what keeps `--prune` off the shared function while the wrong declaration stands.
56
+
57
+ #### [2.13.0] — 2026-08-28
58
+
59
+ - feat(console/functionGate): an extension's Firestore trigger without `forInstalledExtension` is refused. The guard is the half of the withdrawal that acts at the moment somebody clicks Uninstall — `--prune` cannot reach the office that uninstalled while another still has the extension — and nothing cobbled it: `create function` wrote it into the scaffold, and no check in packing, deploying or provisioning ever read the source again. An ungated trigger went up and stayed up, and its symptom is not a failure: it is the code of an extension nobody has any more running over the data of offices that never asked for it, with no error anywhere.
60
+ - feat(console/provision): a guard that names ANOTHER extension's id rejects the extension that claims the function. It is what a copied function leaves behind, and its symptom is the opposite of the ungated one — the trigger deploys, fires on every write, and the handler never runs, because the gate asks about an extension nobody installed. Only provisioning can ask this: the claim comes from the office's database, and the deploy does not have it. The owner is rejected by identity and not by substring, because the message names two ids and matching on text would also reject the extension whose id was copied.
61
+ - feat(console/provision): an ungated trigger already deployed is REPORTED rather than refused. Refusing here would lock `--prune` — the one command that removes the orphan — behind a source it does not need to read. The refusal belongs where the source enters the package, and there it blocks nobody's way out.
62
+ - fix(console/functionGate): comments are stripped before the source is read, and strings are not. The scaffold's own JSDoc cites `forInstalledExtension` to explain it, so deleting the call and keeping the explanation would have passed the check — which is exactly what an author produces while editing generated code. A guard declared in an identifier beside the trigger is accepted, because that is correct code and a check that refuses correct code is the one that teaches the next person to switch it off.
63
+
64
+ #### [2.12.0] — 2026-08-27
65
+
66
+ - feat(pack): the packaged `functions/package.json` gains an `exports` map and `custom/tsconfig.json` a `paths` entry, which is what lets a Cloud Function written by the organization import `microteia-functions/office` — the platform surface that keeps an extension's trigger from running in organizations that do not have it. No new dependency, so the packaged lockfile stays valid.
67
+
68
+ #### [2.11.0] — 2026-08-27
69
+
70
+ - fix(provision): orphan Cloud Functions are reported even when no installed extension asks for a backend at all — the case that mattered most, and the only one the previous shortcut skipped.
71
+ - fix(provision --prune): a function claimed by an extension that is still installed, and one the platform deploys itself, are never deleted. Both were reachable before.
72
+ - feat(provision): the record of applied functions carries the plugin that owns each one, and the orphan report names it along with how many offices were read to reach the conclusion.
73
+
25
74
  #### [2.10.0] — 2026-08-27
26
75
 
27
76
  - feat(export-catalog): a new command writes what an office HAS — its screens with their fields, its
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Microteia Console 2.10.0
1
+ # Microteia Console 2.18.0
2
2
 
3
3
  Installs and updates Microteia in a Firebase project you own. It has no npm dependencies of its own —
4
4
  what it needs are command-line tools that are already on the machine, listed at the bottom.
@@ -0,0 +1,79 @@
1
+ /**
2
+ * O registro do que o provisionamento já deployou — e de QUEM.
3
+ *
4
+ * Ele morava como uma lista de nomes soltos em `backend.functions` do `microteia.yaml`, e a lista de
5
+ * nomes nunca respondeu à única pergunta que a remoção faz: de quem era esta function? Sem o dono, o
6
+ * relatório de uma órfã é "sobrou `acmeSync`", que não é uma decisão — é um enigma para quem herda a
7
+ * instalação meses depois. Com o dono ele é "`acmeSync` era de `com.acme.crm`, que não está instalada
8
+ * em escritório nenhum", que é.
9
+ *
10
+ * A forma gravada é `nome@pluginId`, separada por vírgula, no mesmo escalar de antes. Uma entrada sem
11
+ * `@` continua sendo lida: é o que uma instalação anterior a este campo gravou, e ela vale — o que
12
+ * falta é o dono, não a function.
13
+ */
14
+ export interface AppliedFunction {
15
+ name: string;
16
+ /** O plugin que a reivindicava quando ela subiu. `null` numa instalação anterior ao campo. */
17
+ owner: string | null;
18
+ }
19
+ export declare const parseAppliedFunctions: (raw: string | null) => AppliedFunction[];
20
+ export declare const serializeAppliedFunctions: (applied: AppliedFunction[]) => string | null;
21
+ /** Por que uma function que ninguém exige mais NÃO é órfã. */
22
+ export type PreservedReason = 'baseline' | 'unresolved';
23
+ export interface PreservedFunction {
24
+ entry: AppliedFunction;
25
+ reason: PreservedReason;
26
+ /** O plugin cuja declaração não pôde ser resolvida — só em `unresolved`. */
27
+ claimant?: string;
28
+ }
29
+ export interface AppliedClassification {
30
+ /** Alguma extensão instalada continua exigindo. */
31
+ wanted: AppliedFunction[];
32
+ /** Ninguém exige, e nada impede de remover. */
33
+ orphans: AppliedFunction[];
34
+ /** Ninguém exige, e remover seria errado. */
35
+ preserved: PreservedFunction[];
36
+ }
37
+ /**
38
+ * O que fazer com cada function já aplicada, e a razão de nunca ser só "ninguém pediu".
39
+ *
40
+ * As duas categorias preservadas existem porque "ninguém exige" tem três causas diferentes, e uma
41
+ * delas — a única — autoriza apagar:
42
+ *
43
+ * - `baseline`: a function é da PLATAFORMA. O `deploy` a sobe sempre, independentemente de extensão
44
+ * nenhuma. Ela só chegou a este registro porque uma extensão reivindicou um nome do kernel, e apagá-la
45
+ * derrubaria um gatilho do produto num escritório que não fez nada de errado.
46
+ * - `unresolved`: a extensão que a reivindica CONTINUA instalada, e o que falhou foi resolver a
47
+ * declaração dela — um export renomeado em `extensions/<x>/functions/index.ts`, um upgrade que tirou o
48
+ * nome do pacote. Apagar aqui é apagar a function de uma extensão viva por causa de um defeito de
49
+ * configuração que a próxima execução conserta.
50
+ * - órfã de verdade: nome nenhum a reivindica, e nenhuma das duas condições acima vale. Foi
51
+ * desinstalada de todos os escritórios do projeto.
52
+ */
53
+ export declare const classifyApplied: (input: {
54
+ applied: AppliedFunction[];
55
+ wanted: string[];
56
+ baseline: string[];
57
+ /** Nome de function -> plugin cuja reivindicação foi recusada nesta execução. */
58
+ unresolved: Map<string, string>;
59
+ }) => AppliedClassification;
60
+ /**
61
+ * A lista aplicada CONVERGE para o que ainda é exigido: o que já estava e continua sendo pedido, mais
62
+ * o que subiu agora, mais o órfão que não foi removido, mais o que foi preservado. Reescrevê-la só com
63
+ * o que subiu NESTA execução a esvaziava na segunda passada — e a terceira redeployava tudo, achando
64
+ * que nada havia sido feito.
65
+ *
66
+ * O dono é reconciliado com quem reivindica AGORA: uma function que trocou de extensão entre duas
67
+ * versões fica registrada com a atual, e não com a que a pôs no ar da primeira vez.
68
+ */
69
+ export declare const appliedAfterRun: (input: {
70
+ applied: AppliedFunction[];
71
+ wanted: string[];
72
+ deployed: string[];
73
+ keptOrphans: AppliedFunction[];
74
+ preserved: AppliedFunction[];
75
+ /** Nome de function -> plugin que a reivindica nesta execução. */
76
+ owners: Map<string, string>;
77
+ }) => AppliedFunction[];
78
+ /** Como uma function aplicada aparece num relatório: sem dono conhecido, só o nome. */
79
+ export declare const describeApplied: (entry: AppliedFunction) => string;
@@ -0,0 +1,88 @@
1
+ export const parseAppliedFunctions = (raw) => {
2
+ const applied = [];
3
+ const seen = new Set();
4
+ for (const entry of (raw ?? '').split(',')) {
5
+ const trimmed = entry.trim();
6
+ if (trimmed === '')
7
+ continue;
8
+ const separator = trimmed.indexOf('@');
9
+ const name = separator === -1 ? trimmed : trimmed.slice(0, separator).trim();
10
+ const owner = separator === -1 ? '' : trimmed.slice(separator + 1).trim();
11
+ if (name === '' || seen.has(name))
12
+ continue;
13
+ seen.add(name);
14
+ applied.push({ name, owner: owner === '' ? null : owner });
15
+ }
16
+ return applied;
17
+ };
18
+ export const serializeAppliedFunctions = (applied) => {
19
+ const entries = [...applied]
20
+ .sort((left, right) => left.name.localeCompare(right.name))
21
+ .map((entry) => (entry.owner === null ? entry.name : `${entry.name}@${entry.owner}`));
22
+ return entries.length === 0 ? null : entries.join(',');
23
+ };
24
+ /**
25
+ * O que fazer com cada function já aplicada, e a razão de nunca ser só "ninguém pediu".
26
+ *
27
+ * As duas categorias preservadas existem porque "ninguém exige" tem três causas diferentes, e uma
28
+ * delas — a única — autoriza apagar:
29
+ *
30
+ * - `baseline`: a function é da PLATAFORMA. O `deploy` a sobe sempre, independentemente de extensão
31
+ * nenhuma. Ela só chegou a este registro porque uma extensão reivindicou um nome do kernel, e apagá-la
32
+ * derrubaria um gatilho do produto num escritório que não fez nada de errado.
33
+ * - `unresolved`: a extensão que a reivindica CONTINUA instalada, e o que falhou foi resolver a
34
+ * declaração dela — um export renomeado em `extensions/<x>/functions/index.ts`, um upgrade que tirou o
35
+ * nome do pacote. Apagar aqui é apagar a function de uma extensão viva por causa de um defeito de
36
+ * configuração que a próxima execução conserta.
37
+ * - órfã de verdade: nome nenhum a reivindica, e nenhuma das duas condições acima vale. Foi
38
+ * desinstalada de todos os escritórios do projeto.
39
+ */
40
+ export const classifyApplied = (input) => {
41
+ const wantedNames = new Set(input.wanted);
42
+ const baseline = new Set(input.baseline);
43
+ const classification = { wanted: [], orphans: [], preserved: [] };
44
+ for (const entry of input.applied) {
45
+ if (wantedNames.has(entry.name)) {
46
+ classification.wanted.push(entry);
47
+ continue;
48
+ }
49
+ if (baseline.has(entry.name)) {
50
+ classification.preserved.push({ entry, reason: 'baseline' });
51
+ continue;
52
+ }
53
+ const claimant = input.unresolved.get(entry.name);
54
+ if (claimant !== undefined) {
55
+ classification.preserved.push({ entry, reason: 'unresolved', claimant });
56
+ continue;
57
+ }
58
+ classification.orphans.push(entry);
59
+ }
60
+ return classification;
61
+ };
62
+ /**
63
+ * A lista aplicada CONVERGE para o que ainda é exigido: o que já estava e continua sendo pedido, mais
64
+ * o que subiu agora, mais o órfão que não foi removido, mais o que foi preservado. Reescrevê-la só com
65
+ * o que subiu NESTA execução a esvaziava na segunda passada — e a terceira redeployava tudo, achando
66
+ * que nada havia sido feito.
67
+ *
68
+ * O dono é reconciliado com quem reivindica AGORA: uma function que trocou de extensão entre duas
69
+ * versões fica registrada com a atual, e não com a que a pôs no ar da primeira vez.
70
+ */
71
+ export const appliedAfterRun = (input) => {
72
+ const wanted = new Set(input.wanted);
73
+ const byName = new Map();
74
+ const remember = (name, recorded) => {
75
+ byName.set(name, { name, owner: input.owners.get(name) ?? recorded });
76
+ };
77
+ for (const entry of input.applied) {
78
+ if (wanted.has(entry.name))
79
+ remember(entry.name, entry.owner);
80
+ }
81
+ for (const name of input.deployed)
82
+ remember(name, byName.get(name)?.owner ?? null);
83
+ for (const entry of [...input.keptOrphans, ...input.preserved])
84
+ remember(entry.name, entry.owner);
85
+ return [...byName.values()].sort((left, right) => left.name.localeCompare(right.name));
86
+ };
87
+ /** Como uma function aplicada aparece num relatório: sem dono conhecido, só o nome. */
88
+ export const describeApplied = (entry) => entry.owner === null ? entry.name : `${entry.name} (${entry.owner})`;
@@ -0,0 +1,55 @@
1
+ import type { FirestoreTypedValue } from './firestoreRest.js';
2
+ /**
3
+ * O que o provisionamento descobriu, devolvido para o banco — onde a APLICAÇÃO pode ler.
4
+ *
5
+ * O `provision` é o único ponto do sistema capaz de dizer "esta Cloud Function não tem mais dono": a
6
+ * resposta exige varrer todos os escritórios do projeto e comparar com o registro de aplicadas. Ele
7
+ * dizia isso no terminal e guardava a conclusão no `microteia.yaml` — um arquivo na máquina de quem
8
+ * rodou o comando. Quem clicou em Desinstalar, na aplicação, nunca ficava sabendo de nada; e quem lê a
9
+ * tela de Extensões não é necessariamente quem tem acesso ao projeto Firebase.
10
+ *
11
+ * O efeito era o mesmo em toda instalação: o `--prune` continua sendo um ato explícito — e isso está
12
+ * certo, apagar é destrutivo e irreversível —, mas ninguém tinha como saber que ele era necessário. O
13
+ * mecanismo inteiro dependia de alguém lembrar.
14
+ *
15
+ * A tela de Extensões já mostra o selo "Backend pendente" para o caso da INSTALAÇÃO. Este documento é o
16
+ * par que faltava, o da RETIRADA, e não inventa caminho novo: o Console já escreve no banco pela REST
17
+ * (é assim que `backendState` é carimbado), e o pacote já tem uma coleção global de espelho somente
18
+ * leitura escrita só com credencial de administrador (`workflow_step_catalog`).
19
+ */
20
+ /**
21
+ * Um documento só, de nome fixo. A órfã é um fato do PROJETO — não de um escritório —, então guardá-la
22
+ * por tenant seria guardar a mesma resposta N vezes e deixar N maneiras de ela divergir.
23
+ */
24
+ export declare const BACKEND_RESIDUE_PATH = "/backend_state/current";
25
+ export interface OrphanFunctionRecord {
26
+ name: string;
27
+ /** O plugin que a reivindicava quando ela subiu. Vazio numa instalação anterior ao registro com dono. */
28
+ owner: string;
29
+ }
30
+ export interface BackendResidueInput {
31
+ /** Quando a varredura aconteceu. É o que permite a quem lê saber se a informação é de hoje. */
32
+ scannedAt: Date;
33
+ /** Escritórios lidos, e cópias de extensão lidas: a contagem que SUSTENTA a afirmação. */
34
+ tenants: number;
35
+ extensions: number;
36
+ functions: OrphanFunctionRecord[];
37
+ /** O índice órfão, na forma legível que o relatório do terminal também usa. */
38
+ indexes: string[];
39
+ }
40
+ /**
41
+ * Os campos do documento, montados sem tocar na rede.
42
+ *
43
+ * Separado da escrita porque é a FORMA que é contrato: ela é lida pelo kernel, que é versionado à parte
44
+ * do Console. Um campo renomeado aqui é uma tela que deixa de avisar, em silêncio — e é essa a
45
+ * verificação que o teste faz.
46
+ */
47
+ export declare const backendResidueFields: (input: BackendResidueInput) => Record<string, FirestoreTypedValue>;
48
+ /**
49
+ * Escreve o resíduo, SEMPRE — inclusive quando não há nenhum.
50
+ *
51
+ * Escrever só quando há órfã deixaria o aviso na tela depois de o `--prune` já ter resolvido, e um aviso
52
+ * que não sai é um aviso que se aprende a ignorar. Como todos os campos são escritos a cada execução, a
53
+ * lista vazia é o que apaga a anterior.
54
+ */
55
+ export declare const writeBackendResidue: (projectId: string, input: BackendResidueInput) => Promise<void>;
@@ -0,0 +1,53 @@
1
+ import { asArray, asInteger, asMap, asString, asTimestamp, patchFields } from './firestoreRest.js';
2
+ import { CLI_VERSION } from '../shared/paths.js';
3
+ /**
4
+ * O que o provisionamento descobriu, devolvido para o banco — onde a APLICAÇÃO pode ler.
5
+ *
6
+ * O `provision` é o único ponto do sistema capaz de dizer "esta Cloud Function não tem mais dono": a
7
+ * resposta exige varrer todos os escritórios do projeto e comparar com o registro de aplicadas. Ele
8
+ * dizia isso no terminal e guardava a conclusão no `microteia.yaml` — um arquivo na máquina de quem
9
+ * rodou o comando. Quem clicou em Desinstalar, na aplicação, nunca ficava sabendo de nada; e quem lê a
10
+ * tela de Extensões não é necessariamente quem tem acesso ao projeto Firebase.
11
+ *
12
+ * O efeito era o mesmo em toda instalação: o `--prune` continua sendo um ato explícito — e isso está
13
+ * certo, apagar é destrutivo e irreversível —, mas ninguém tinha como saber que ele era necessário. O
14
+ * mecanismo inteiro dependia de alguém lembrar.
15
+ *
16
+ * A tela de Extensões já mostra o selo "Backend pendente" para o caso da INSTALAÇÃO. Este documento é o
17
+ * par que faltava, o da RETIRADA, e não inventa caminho novo: o Console já escreve no banco pela REST
18
+ * (é assim que `backendState` é carimbado), e o pacote já tem uma coleção global de espelho somente
19
+ * leitura escrita só com credencial de administrador (`workflow_step_catalog`).
20
+ */
21
+ /**
22
+ * Um documento só, de nome fixo. A órfã é um fato do PROJETO — não de um escritório —, então guardá-la
23
+ * por tenant seria guardar a mesma resposta N vezes e deixar N maneiras de ela divergir.
24
+ */
25
+ export const BACKEND_RESIDUE_PATH = '/backend_state/current';
26
+ /**
27
+ * Os campos do documento, montados sem tocar na rede.
28
+ *
29
+ * Separado da escrita porque é a FORMA que é contrato: ela é lida pelo kernel, que é versionado à parte
30
+ * do Console. Um campo renomeado aqui é uma tela que deixa de avisar, em silêncio — e é essa a
31
+ * verificação que o teste faz.
32
+ */
33
+ export const backendResidueFields = (input) => ({
34
+ scannedAt: asTimestamp(input.scannedAt),
35
+ consoleVersion: asString(CLI_VERSION),
36
+ tenants: asInteger(input.tenants),
37
+ extensions: asInteger(input.extensions),
38
+ orphanFunctions: asArray(input.functions.map((entry) => asMap({
39
+ name: asString(entry.name),
40
+ owner: asString(entry.owner),
41
+ }))),
42
+ orphanIndexes: asArray(input.indexes.map((entry) => asString(entry))),
43
+ });
44
+ /**
45
+ * Escreve o resíduo, SEMPRE — inclusive quando não há nenhum.
46
+ *
47
+ * Escrever só quando há órfã deixaria o aviso na tela depois de o `--prune` já ter resolvido, e um aviso
48
+ * que não sai é um aviso que se aprende a ignorar. Como todos os campos são escritos a cada execução, a
49
+ * lista vazia é o que apaga a anterior.
50
+ */
51
+ export const writeBackendResidue = async (projectId, input) => {
52
+ await patchFields(projectId, BACKEND_RESIDUE_PATH, backendResidueFields(input));
53
+ };
@@ -1,4 +1,5 @@
1
1
  import type { BackendParameter } from '../kernel/manifest.js';
2
+ import type { TriggerDeclaration } from './functionGate.js';
2
3
  /**
3
4
  * As Cloud Functions do ESCRITÓRIO — as que ele escreve, não as do kernel.
4
5
  *
@@ -37,10 +38,35 @@ export interface FunctionSource {
37
38
  shared: boolean;
38
39
  /** As callables — as únicas que o navegador chama, e por isso as únicas com alcance a conferir. */
39
40
  callables: string[];
41
+ /**
42
+ * As callables com o estado da guarda de instalação de cada uma. Separado de `callables` porque
43
+ * responde a outra pergunta: aquela é "o navegador alcança?", esta é "quem mais alcança?".
44
+ */
45
+ callableGuards: TriggerDeclaration[];
46
+ /**
47
+ * Os gatilhos de Firestore declarados aqui, com o estado do portão de instalação de cada um. É o que
48
+ * permite recusar um gatilho de extensão que dispararia para todos os escritórios do projeto.
49
+ */
50
+ triggers: TriggerDeclaration[];
51
+ /**
52
+ * As functions cujo evento NÃO carrega escritório — agendada, fila, HTTP, Storage. Para elas o portão
53
+ * não serve (falha fechado), e quem responde é o laço por escritório.
54
+ */
55
+ unscoped: TriggerDeclaration[];
40
56
  /** Os `defineString` que ela declara. O `firebase-functions` os valida GLOBALMENTE (ver abaixo). */
41
57
  parameters: BackendParameter[];
42
58
  /** As dependências npm que ela declara no `package.json` do próprio diretório. */
43
59
  dependencies: Record<string, string>;
60
+ /**
61
+ * Quem cada function COMUM serve, de `microteia.serves` no `package.json` do diretório.
62
+ *
63
+ * É a declaração que tira `functions/` da invisibilidade. A function de extensão tem dono pelo LUGAR
64
+ * do arquivo; a comum não tem nenhum — nenhuma extensão pode reivindicá-la —, e por isso ela ficava
65
+ * fora do mecanismo de retirada por inteiro: nunca entrava no registro de aplicadas, nunca era
66
+ * classificada como órfã, nunca era relatada, e o `--prune` não a alcançava. Ficava no ar para sempre,
67
+ * com todas as extensões que ela servia desinstaladas.
68
+ */
69
+ serves: Record<string, string[]>;
44
70
  }
45
71
  /**
46
72
  * Os nomes que um módulo de entrada exporta. Cobre as duas formas que se escreve de fato: o
@@ -55,12 +81,28 @@ export declare const findFunctionSources: (root: string) => Promise<FunctionSour
55
81
  * `shared` e `owned` respondem a perguntas diferentes, e é por isso que são duas listas: a comum sobe
56
82
  * no `deploy`, sempre; a de extensão sobe pelo provisionamento, e só onde a extensão está instalada.
57
83
  */
84
+ /** Uma function comum e as extensões que ela declara servir. */
85
+ export interface SharedRequirement {
86
+ name: string;
87
+ serves: string[];
88
+ }
58
89
  export interface OfficeFunctions {
59
90
  sources: FunctionSource[];
60
91
  /** Todos os nomes, na ordem em que as origens são lidas. */
61
92
  all: string[];
62
- /** As de `functions/` — sem dono, e por isso deployadas sempre. */
93
+ /** As de `functions/`, declarem ou não a quem servem. */
63
94
  shared: string[];
95
+ /**
96
+ * As comuns que DECLARAM quem servem. Elas entram no mecanismo de retirada: sobem pelo
97
+ * provisionamento enquanto alguma das extensões servidas estiver instalada, e viram órfãs quando
98
+ * nenhuma estiver.
99
+ */
100
+ served: SharedRequirement[];
101
+ /**
102
+ * As comuns SEM declaração. Sobem no `deploy`, sempre, e nada as retira — é o comportamento anterior,
103
+ * mantido para não quebrar instalação nenhuma, e relatado para não seguir invisível.
104
+ */
105
+ unserved: string[];
64
106
  callables: string[];
65
107
  parameters: BackendParameter[];
66
108
  }
@@ -76,6 +118,14 @@ export declare const readOfficeFunctions: (root: string) => Promise<OfficeFuncti
76
118
  * silêncio — e substituir um gatilho do kernel é o tipo de defeito que só aparece quando algo deixa de
77
119
  * acontecer, sem erro em lugar nenhum.
78
120
  */
121
+ /**
122
+ * O que impede `microteia.serves` de ser levado a sério.
123
+ *
124
+ * As três recusas têm o mesmo motivo: a declaração é o que autoriza a plataforma a RETIRAR a function
125
+ * comum, e uma declaração que não fecha faria a retirada acontecer pelo motivo errado — ou não acontecer,
126
+ * e nada diria por quê.
127
+ */
128
+ export declare const servesProblems: (sources: FunctionSource[]) => string[];
79
129
  export declare const functionProblems: (sources: FunctionSource[], kernelFunctions: string[]) => string[];
80
130
  /**
81
131
  * Duas declarações da MESMA dependência com faixas diferentes, entre diretórios do escritório.
@@ -1,6 +1,7 @@
1
1
  import { access, cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
2
2
  import { basename, join } from 'node:path';
3
3
  import { callableFunctionNames, declaredParametersIn } from '../kernel/manifest.js';
4
+ import { callableDeclarations, triggerDeclarations, ungatedTriggerProblems, unguardedCallableProblems, unguardedSharedProblems, unscopedDeclarations, unscopedTriggerProblems, } from './functionGate.js';
4
5
  /**
5
6
  * As Cloud Functions do ESCRITÓRIO — as que ele escreve, não as do kernel.
6
7
  *
@@ -79,6 +80,33 @@ const readDependencies = async (path, label) => {
79
80
  }
80
81
  return parsed.dependencies;
81
82
  };
83
+ /**
84
+ * `microteia.serves` do `package.json` do diretório: nome de function -> os ids que ela serve.
85
+ *
86
+ * OPCIONAL, como as `dependencies` ao lado. A ausência dela é o comportamento anterior — a function comum
87
+ * sobe sempre e nunca é retirada —, e o provisionamento diz quais estão nessa situação em vez de calar.
88
+ */
89
+ const readServes = async (path, label) => {
90
+ const manifest = join(path, 'package.json');
91
+ if (!await exists(manifest))
92
+ return {};
93
+ const parsed = JSON.parse(await readFile(manifest, 'utf8'));
94
+ const declared = parsed.microteia?.serves;
95
+ if (declared === undefined)
96
+ return {};
97
+ if (typeof declared !== 'object' || declared === null || Array.isArray(declared)) {
98
+ throw new Error(`${label}/package.json has a "microteia.serves" that is not an object.`);
99
+ }
100
+ const serves = {};
101
+ for (const [name, ids] of Object.entries(declared)) {
102
+ if (!Array.isArray(ids) || ids.some((id) => typeof id !== 'string')) {
103
+ throw new Error(`${label}/package.json declares "microteia.serves.${name}" as something other than a list of `
104
+ + 'extension ids.');
105
+ }
106
+ serves[name] = [...new Set(ids)];
107
+ }
108
+ return serves;
109
+ };
82
110
  const readSource = async (path, label, slug, shared) => {
83
111
  const entry = join(path, 'index.ts');
84
112
  if (!await exists(entry)) {
@@ -88,11 +116,19 @@ const readSource = async (path, label, slug, shared) => {
88
116
  }
89
117
  const exported = exportedNames(await readFile(entry, 'utf8'));
90
118
  const callables = new Set();
119
+ const triggers = [];
120
+ const unscoped = [];
121
+ const callableGuards = [];
91
122
  const parameters = new Map();
92
123
  for (const file of await sourceFiles(path)) {
93
124
  const source = await readFile(file, 'utf8');
94
125
  for (const name of callableFunctionNames(source))
95
126
  callables.add(name);
127
+ // O gatilho é declarado no arquivo dele e reexportado pelo `index.ts` — é o que o molde escreve —,
128
+ // então a varredura é a mesma das callables: todo `.ts`, e o `index.ts` decide o que conta.
129
+ triggers.push(...triggerDeclarations(source));
130
+ unscoped.push(...unscopedDeclarations(source));
131
+ callableGuards.push(...callableDeclarations(source));
96
132
  // Um nome declarado em vários lugares é UM parâmetro, e ele é exigido se ALGUMA declaração o pede
97
133
  // sem `default`: é essa que recusa o deploy quando o valor falta, e a conservadora é a que avisa.
98
134
  for (const parameter of declaredParametersIn(source)) {
@@ -112,8 +148,12 @@ const readSource = async (path, label, slug, shared) => {
112
148
  // Só as que o `index.ts` de fato exporta: uma callable escrita e não exportada não é deployada, e
113
149
  // conferir o alcance dela mandaria procurar um erro que não existe.
114
150
  callables: exported.filter((name) => callables.has(name)),
151
+ triggers,
152
+ unscoped,
153
+ callableGuards,
115
154
  parameters: [...parameters.values()].sort((a, b) => a.name.localeCompare(b.name)),
116
155
  dependencies: await readDependencies(path, label),
156
+ serves: await readServes(path, label),
117
157
  };
118
158
  };
119
159
  /** Onde o escritório pode ter escrito function, na ordem em que as mensagens as citam. */
@@ -146,10 +186,21 @@ export const officeFunctionsOf = (sources) => {
146
186
  });
147
187
  }
148
188
  }
189
+ const shared = sources.filter((source) => source.shared);
190
+ const declared = new Map();
191
+ for (const source of shared) {
192
+ for (const [name, ids] of Object.entries(source.serves))
193
+ declared.set(name, ids);
194
+ }
195
+ const sharedNames = shared.flatMap((source) => source.exported);
149
196
  return {
150
197
  sources,
151
198
  all: sources.flatMap((source) => source.exported),
152
- shared: sources.filter((source) => source.shared).flatMap((source) => source.exported),
199
+ shared: sharedNames,
200
+ served: sharedNames
201
+ .filter((name) => declared.has(name))
202
+ .map((name) => ({ name, serves: declared.get(name) ?? [] })),
203
+ unserved: sharedNames.filter((name) => !declared.has(name)),
153
204
  callables: sources.flatMap((source) => source.callables),
154
205
  parameters: [...parameters.values()].sort((a, b) => a.name.localeCompare(b.name)),
155
206
  };
@@ -165,6 +216,41 @@ export const readOfficeFunctions = async (root) => officeFunctionsOf(await findF
165
216
  * silêncio — e substituir um gatilho do kernel é o tipo de defeito que só aparece quando algo deixa de
166
217
  * acontecer, sem erro em lugar nenhum.
167
218
  */
219
+ /**
220
+ * O que impede `microteia.serves` de ser levado a sério.
221
+ *
222
+ * As três recusas têm o mesmo motivo: a declaração é o que autoriza a plataforma a RETIRAR a function
223
+ * comum, e uma declaração que não fecha faria a retirada acontecer pelo motivo errado — ou não acontecer,
224
+ * e nada diria por quê.
225
+ */
226
+ export const servesProblems = (sources) => {
227
+ const problems = [];
228
+ for (const source of sources) {
229
+ const declared = Object.entries(source.serves);
230
+ if (declared.length === 0)
231
+ continue;
232
+ // A function de extensão já tem dono, e ele vem do LUGAR do arquivo. Declarar de novo, e num lugar
233
+ // que só a comum consulta, criaria duas autoridades sobre a mesma retirada.
234
+ if (!source.shared) {
235
+ problems.push(`${source.label}/package.json declares "microteia.serves", which only ${FUNCTIONS_DIR}/ uses — a `
236
+ + "function of an extension is already owned by the folder it is in, and is declared in that "
237
+ + 'extension\'s manifest.backend.functions');
238
+ continue;
239
+ }
240
+ const exported = new Set(source.exported);
241
+ for (const [name, ids] of declared) {
242
+ if (!exported.has(name)) {
243
+ problems.push(`${source.label}/package.json says "${name}" serves ${ids.join(', ') || 'nothing'}, but `
244
+ + `${source.label}/index.ts does not export it`);
245
+ }
246
+ if (ids.length === 0) {
247
+ problems.push(`${source.label}/package.json declares "${name}" as serving no extension at all — name the `
248
+ + 'extensions it exists for, or leave it out of microteia.serves');
249
+ }
250
+ }
251
+ }
252
+ return problems;
253
+ };
168
254
  export const functionProblems = (sources, kernelFunctions) => {
169
255
  const problems = [];
170
256
  const kernel = new Set(kernelFunctions);
@@ -186,6 +272,13 @@ export const functionProblems = (sources, kernelFunctions) => {
186
272
  }
187
273
  }
188
274
  }
275
+ problems.push(...servesProblems(sources));
276
+ // O portão de instalação entra aqui, e não numa verificação à parte, porque o momento é o mesmo: é
277
+ // este o ponto em que a fonte do escritório passa a fazer parte do pacote que vai subir.
278
+ problems.push(...ungatedTriggerProblems(sources));
279
+ problems.push(...unscopedTriggerProblems(sources));
280
+ problems.push(...unguardedSharedProblems(sources));
281
+ problems.push(...unguardedCallableProblems(sources));
189
282
  return problems;
190
283
  };
191
284
  const barrel = (sources) => `// Generated by "microteia deploy". Do not edit.
@@ -125,17 +125,27 @@ export const deploy = async (args) => {
125
125
  // comuns) e `extensions/<plugin>/functions/` (as de cada extensão). O `predeploy` do pacote compila
126
126
  // só o `custom/`, então o que não estiver aqui não sobe.
127
127
  const gathered = await gatherCustomFunctions(root, install, manifest.functions);
128
- // O que sobe aqui é a baseline MAIS as functions comuns do escritório (`functions/`). As de
129
- // `extensions/<plugin>/functions/` têm dono e sobem pelo provisionamento, no fim deste comando:
130
- // é o mesmo "empacotar não é deployar" que vale para as do kernel reivindicadas por extensão.
131
- const own = [...baseline, ...gathered.shared];
128
+ /**
129
+ * O que sobe aqui é a baseline MAIS as functions comuns que NÃO dizem a quem servem.
130
+ *
131
+ * As de `extensions/<plugin>/functions/` têm dono e sobem pelo provisionamento, no fim deste
132
+ * comando — é o mesmo "empacotar não é deployar" que vale para as do kernel reivindicadas por
133
+ * extensão. E a comum que DECLARA quem serve passou a ter a mesma condição, pela mesma razão: ela
134
+ * é devida enquanto alguma das extensões servidas estiver instalada, e subi-la aqui de qualquer
135
+ * forma faria o `--prune` apagá-la e este passo repô-la, indefinidamente.
136
+ *
137
+ * A comum sem declaração continua subindo sempre. É o comportamento anterior, e mantê-lo é o que
138
+ * impede esta versão de tirar do ar function de uma instalação que já existe.
139
+ */
140
+ const own = [...baseline, ...gathered.unserved];
132
141
  step(current, total, `Cloud Functions (${own.length}, one at a time)`);
133
142
  if (gathered.sources.length > 0) {
134
143
  success(`${gathered.functions.length} function(s) of your own, from `
135
144
  + `${gathered.sources.map((source) => `${source.label}/`).join(', ')}`);
136
- const owned = gathered.all.length - gathered.shared.length;
137
- if (owned > 0)
138
- info(` ${owned} of them belong to an extension and go up with "provision"`);
145
+ const provisioned = gathered.all.length - gathered.unserved.length;
146
+ if (provisioned > 0) {
147
+ info(` ${provisioned} of them go up with "provision", only where they are needed`);
148
+ }
139
149
  }
140
150
  if (Object.keys(gathered.dependencies.added).length > 0) {
141
151
  success(`${Object.keys(gathered.dependencies.added).length} npm dependency(ies) your functions declare: `