@microteia/console 2.10.0 → 2.19.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.
@@ -2,7 +2,7 @@ import { dirname, join, resolve } from 'node:path';
2
2
  import { hasFlag, requiredFlag, flagValue } from '../shared/args.js';
3
3
  import { run } from '../shared/run.js';
4
4
  import { blank, bullets, failure, heading, info, step, success, warn } from '../shared/ui.js';
5
- import { backendFingerprint, claimedIndexes, claimedParameters, functionClaimProblems, hasBackendRequirement, indexKey, } from '../kernel/backendShape.js';
5
+ import { backendFingerprint, baselineClaimProblems, claimedIndexes, claimedParameters, functionClaimProblems, hasBackendRequirement, indexKey, sharedClaimProblems, } from '../kernel/backendShape.js';
6
6
  import { convergeIndexes, describeIndex, parseIndexState, readIndexFile, serializeIndexState, writeIndexFile, } from './backendIndexes.js';
7
7
  import { officeEnvPath, stageOfficeEnv } from './backendEnv.js';
8
8
  import { installPath, readInstalledManifest } from './unpack.js';
@@ -14,8 +14,11 @@ import { clearRegionOrphans, listDeployedFunctions } from './regionOrphans.js';
14
14
  import { outdatedByStamp } from './functionsUpToDate.js';
15
15
  import { checkCallables, invokerGrantCommand } from './callableAccess.js';
16
16
  import { asString, asTimestamp, patchMapField } from './firestoreRest.js';
17
+ import { writeBackendResidue } from './backendResidue.js';
17
18
  import { listTenants, readInstalledExtensions } from './installedExtensions.js';
18
19
  import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js';
20
+ import { describeMisgated, gateProblems, misgatedTriggers, singleClaimants, } from './functionGate.js';
21
+ import { appliedAfterRun, classifyApplied, describeApplied, parseAppliedFunctions, serializeAppliedFunctions, straysInProject, } from './appliedFunctions.js';
19
22
  /**
20
23
  * O que cada escritório do projeto instalou e exige do backend.
21
24
  *
@@ -23,13 +26,17 @@ import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js
23
26
  * contra `manifest.functions` antes de qualquer deploy. Nome de function fora do pacote é recusado com
24
27
  * explicação; não há injeção possível, porque o código é sempre o do pacote.
25
28
  */
26
- export const readInstalledClaims = async (projectId) => {
29
+ export const surveyInstallations = async (projectId) => {
27
30
  const tenants = await listTenants(projectId);
28
31
  const claims = [];
32
+ const installedIds = new Set();
33
+ let extensions = 0;
29
34
  for (const tenant of tenants) {
30
35
  for (const extension of await readInstalledExtensions(projectId, tenant.id)) {
36
+ extensions += 1;
31
37
  if (!extension.active)
32
38
  continue;
39
+ installedIds.add(extension.id);
33
40
  const backend = extension.manifest?.backend;
34
41
  if (!hasBackendRequirement(backend))
35
42
  continue;
@@ -45,7 +52,12 @@ export const readInstalledClaims = async (projectId) => {
45
52
  });
46
53
  }
47
54
  }
48
- return claims.sort((a, b) => (a.pluginId < b.pluginId ? -1 : a.pluginId > b.pluginId ? 1 : 0));
55
+ return {
56
+ tenants: tenants.length,
57
+ extensions,
58
+ claims: claims.sort((a, b) => (a.pluginId < b.pluginId ? -1 : a.pluginId > b.pluginId ? 1 : 0)),
59
+ installedIds,
60
+ };
49
61
  };
50
62
  const uniqueByPlugin = (claims) => {
51
63
  const seen = new Set();
@@ -56,7 +68,6 @@ const uniqueByPlugin = (claims) => {
56
68
  return true;
57
69
  });
58
70
  };
59
- export const parseAppliedFunctions = (value) => (value ?? '').split(',').map((name) => name.trim()).filter((name) => name !== '');
60
71
  /**
61
72
  * O motor: alinha o projeto Firebase com o que as extensões instaladas exigem, e carimba nelas o
62
73
  * estado. Um motor só, chamado pelo `deploy` no fim e pelo `provision` sozinho — duas implementações
@@ -77,12 +88,26 @@ export const provisionBackend = async (options) => {
77
88
  */
78
89
  const office = await readOfficeFunctions(root);
79
90
  const deployable = [...manifest.functions, ...office.all];
80
- const claims = await readInstalledClaims(projectId);
81
- const problems = functionClaimProblems(claims, deployable);
91
+ const survey = await surveyInstallations(projectId);
92
+ const claims = survey.claims;
93
+ const missing = functionClaimProblems(claims, deployable);
94
+ const overlapping = baselineClaimProblems(claims, manifest.baselineFunctions ?? []);
95
+ // A comum do escritório tem o MESMO desfecho da function da plataforma, e por isso a mesma recusa: o
96
+ // `deploy` a sobe sempre, então reivindicá-la não muda nada — até o dia em que `--prune` a apaga.
97
+ const appropriated = sharedClaimProblems(claims, office.shared);
98
+ // O portão aberto para o id de OUTRA extensão. Só aqui se pode perguntar: é a reivindicação que diz de
99
+ // quem a function é, e ela vem do banco do escritório — o `deploy` não a tem.
100
+ const misgated = misgatedTriggers(office.sources, singleClaimants(claims));
101
+ const problems = [
102
+ ...missing,
103
+ ...overlapping,
104
+ ...appropriated,
105
+ ...misgated.map(describeMisgated),
106
+ ];
82
107
  const rejected = new Set();
83
- if (problems.length > 0) {
108
+ if (missing.length > 0) {
84
109
  warn('some extensions ask for backend artifacts that are nowhere to be found:');
85
- bullets(problems);
110
+ bullets(missing);
86
111
  // A mensagem sozinha é um beco sem saída: ela diz que o nome não existe, e não onde ele deveria
87
112
  // existir. As duas respostas possíveis são estas, e a segunda é a que mais acontece — a function é
88
113
  // da extensão, o autor a declarou, e ninguém a exportou do `index.ts` do diretório dela.
@@ -93,18 +118,96 @@ export const provisionBackend = async (options) => {
93
118
  ]);
94
119
  info(` this installation currently exports ${office.all.length} function(s) of its own`
95
120
  + `${office.all.length === 0 ? '' : `: ${office.all.join(', ')}`}`);
121
+ }
122
+ if (overlapping.length > 0) {
123
+ warn('some extensions claim a function that belongs to the platform itself:');
124
+ bullets(overlapping);
125
+ info(' every "microteia deploy" puts those up, extension or no extension, so claiming one changes');
126
+ info(' nothing while it is installed — and takes a working trigger out of this project the day it');
127
+ info(' is uninstalled. Rename the extension\'s own function, or drop the name from its');
128
+ info(' manifest.backend.functions.');
129
+ }
130
+ if (appropriated.length > 0) {
131
+ warn('some extensions claim a function that is shared by this whole installation:');
132
+ bullets(appropriated);
133
+ info(' functions/ next to microteia.yaml is the place for a function that serves SEVERAL');
134
+ info(' extensions, so it has no owner and every "microteia deploy" puts it up. Claiming one changes');
135
+ info(' nothing while the extension is installed — and the day it is uninstalled the function turns');
136
+ info(' into an orphan, --prune deletes it, and the next deploy puts it back. Whoever else depended');
137
+ info(' on it loses their backend in a window nothing explains. Move the function into');
138
+ info(' extensions/<folder>/functions/ if it is really this extension\'s, drop the name from its');
139
+ info(' manifest.backend.functions, or — if several extensions need it — name them in "microteia" ->');
140
+ info(' "serves" in functions/package.json, which is how a shared function gets withdrawn.');
141
+ }
142
+ /**
143
+ * O portão é RELATADO por inteiro aqui, e COBRADO uma a uma na fila de deploy, mais abaixo.
144
+ *
145
+ * A assimetria é deliberada, e é o que este comando exige: ele é o único capaz de RETIRAR uma function
146
+ * do projeto. Recusar o comando inteiro trancaria `--prune` — que é justamente quem apaga a órfã — por
147
+ * causa de uma fonte que a órfã não tem nada a ver. Então a fonte defeituosa sai da fila, é dita em voz
148
+ * alta, o comando termina em erro por causa dela, e tudo o mais acontece.
149
+ *
150
+ * O relato é de TODAS, e não só das que estão subindo agora: uma fonte com portão faltando é uma
151
+ * function que ninguém vai conseguir deployar, e descobrir isso no dia da instalação da extensão é
152
+ * tarde. Aqui o que ela custa é uma linha.
153
+ */
154
+ const gateFaults = gateProblems(office.sources);
155
+ const gateFaultBy = new Map(gateFaults.map((fault) => [fault.name, fault.message]));
156
+ if (gateFaults.length > 0) {
157
+ warn(`${gateFaults.length} function(s) written in this installation act outside any office's`
158
+ + ' installation:');
159
+ bullets(gateFaults.map((fault) => fault.message));
160
+ info(' each one is refused the moment this command tries to deploy IT — the others go up as');
161
+ info(' usual, and a source sitting here for an extension nobody installed blocks nothing.');
162
+ info(' Whatever is already in the air keeps acting on every office in this project.');
163
+ }
164
+ /**
165
+ * A comum SEM declaração de quem serve. Ela continua subindo no `deploy`, sempre, e nada a retira — é
166
+ * o comportamento anterior, mantido para não quebrar instalação nenhuma.
167
+ *
168
+ * O que muda é que ele deixa de ser invisível. Era esse o defeito: quem herdasse a instalação não tinha
169
+ * como saber que aquelas functions estavam fora do mecanismo de retirada, e a única forma de descobrir
170
+ * era um gatilho disparando anos depois de a última extensão que o usava ter saído.
171
+ */
172
+ if (office.unserved.length > 0) {
173
+ blank();
174
+ info(` ${office.unserved.length} shared function(s) do not say which extensions they serve:`);
175
+ bullets(office.unserved);
176
+ info(' they go up with every "microteia deploy" and nothing here withdraws them — not even --prune.');
177
+ info(' Name the extensions each one exists for in "microteia" -> "serves" in functions/package.json,');
178
+ info(' and they are deployed only while one of those is installed, and removed when none is.');
179
+ }
180
+ if (misgated.length > 0) {
181
+ warn('some extensions gate a trigger for an extension id that is not their own:');
182
+ bullets(misgated.map(describeMisgated));
183
+ info(' it is what a copied function leaves behind. The trigger deploys, fires on every write, and');
184
+ info(' the handler never runs — the gate asks about an extension nobody installed. Fix the id in');
185
+ info(' the forInstalledExtension call.');
186
+ }
187
+ if (problems.length > 0) {
96
188
  for (const claim of claims) {
97
189
  if (problems.some((problem) => problem.includes(claim.pluginId)))
98
190
  rejected.add(claim.pluginId);
99
191
  }
192
+ // O dono de um portão errado é pendurado por IDENTIDADE, e não por o id dele aparecer no texto: a
193
+ // mensagem cita DOIS ids, e casar por substring penduraria também a extensão cujo id foi copiado.
194
+ for (const entry of misgated)
195
+ rejected.add(entry.owner);
100
196
  }
101
197
  const valid = uniqueByPlugin(claims.filter((claim) => !rejected.has(claim.pluginId)));
102
198
  /**
103
- * Os índices são resolvidos ANTES do desvio de "nenhuma extensão exige nada" — e não depois, onde
104
- * estavam. Desinstalar a única extensão com backend caía justamente naquele desvio, então o índice
105
- * dela não era relatado por ninguém: o aviso existia para o caso de sobrar um órfão entre outras
106
- * extensões, e faltava no caso de sobrar sozinho.
199
+ * O nome que uma reivindicação RECUSADA carrega. Ele não entra em `wantedFunctions` — não há como
200
+ * deployá-lo —, mas também não pode virar órfão: a extensão que o pede continua instalada, e o que
201
+ * falhou foi resolver a declaração dela. Sem esta lista, um export renomeado em
202
+ * `extensions/<x>/functions/index.ts` fazia `--prune` apagar a function de uma extensão viva.
107
203
  */
204
+ const unresolved = new Map();
205
+ for (const claim of claims) {
206
+ if (!rejected.has(claim.pluginId))
207
+ continue;
208
+ for (const name of claim.backend.functions ?? [])
209
+ unresolved.set(name, claim.pluginId);
210
+ }
108
211
  if (valid.length === 0) {
109
212
  info(' no installed extension asks for backend artifacts');
110
213
  }
@@ -113,14 +216,60 @@ export const provisionBackend = async (options) => {
113
216
  bullets(valid.map((claim) => `${claim.name} ${claim.version} (${claim.pluginId})`));
114
217
  }
115
218
  const settled = await settleIndexes(options, valid);
116
- if (valid.length === 0) {
117
- await recordBackendState(options, parseAppliedFunctions(options.environment.backend.functions), parseParameterState(options.environment.backend.parameters), settled.declared);
118
- reportOrphanIndexes(projectId, settled.orphans, options.prune);
119
- return outcome;
219
+ /**
220
+ * NÃO existe mais um desvio de "nenhuma extensão exige nada" aqui — e é a correção do defeito que
221
+ * este comando tinha justamente no caso mais comum. Desinstalar a ÚLTIMA extensão com backend caía
222
+ * naquele desvio, e o desvio devolvia antes do cálculo de órfã: a Cloud Function dela continuava no
223
+ * ar para sempre, `--prune` não a alcançava, e a lista de aplicadas era regravada intacta. O sintoma
224
+ * era um gatilho `onDocumentWritten` de uma extensão que ninguém tem mais, disparando sobre os dados
225
+ * do escritório a cada gravação.
226
+ *
227
+ * O índice já havia sido salvo dessa mesma armadilha — foi por isso que `settleIndexes` subiu para
228
+ * antes do desvio. A function ficou para trás, e a lição é que o desvio é que estava errado: o que
229
+ * ele pulava não era "trabalho inútil", era a retirada.
230
+ *
231
+ * O que sobrou dele são duas guardas pontuais mais abaixo: não perguntar ao projeto o que ele roda
232
+ * quando não há nada a conferir, e não anunciar que "as 0 functions já estão deployadas".
233
+ */
234
+ /**
235
+ * A function COMUM que declara quem serve entra aqui, e é o que a tira da invisibilidade.
236
+ *
237
+ * Ela não tem dono que possa reivindicá-la — nenhuma extensão pode —, então ela ficava fora do
238
+ * mecanismo por inteiro: nunca entrava no registro de aplicadas, nunca virava órfã, nunca era
239
+ * relatada, e o `--prune` não a alcançava. Com a declaração, a pergunta passa a ter resposta: ela é
240
+ * devida enquanto ALGUMA das extensões que ela serve estiver instalada em algum escritório do projeto.
241
+ *
242
+ * "Basta uma" é a única regra possível: a function existe porque mais de uma extensão precisa dela.
243
+ * Exigir todas faria desinstalar uma desligar o backend das outras.
244
+ */
245
+ const sharedWanted = office.served.filter((entry) => entry.serves.some((id) => survey.installedIds.has(id)));
246
+ const sharedWantedNames = new Set(sharedWanted.map((entry) => entry.name));
247
+ const wantedFunctions = deployable.filter((name) => sharedWantedNames.has(name)
248
+ || valid.some((claim) => (claim.backend.functions ?? []).includes(name)));
249
+ /** Quem reivindica cada nome AGORA — é com este dono que o registro é reconciliado. */
250
+ const owners = new Map();
251
+ for (const claim of valid) {
252
+ for (const name of claim.backend.functions ?? [])
253
+ owners.set(name, claim.pluginId);
120
254
  }
121
- const wantedFunctions = deployable.filter((name) => valid.some((claim) => (claim.backend.functions ?? []).includes(name)));
122
- const alreadyApplied = parseAppliedFunctions(options.environment.backend.functions);
123
- const trackedParameters = claimedParameters(valid);
255
+ // A comum tem VÁRIOS donos, e o registro guarda a lista declarada em vez de eleger um deles: é ela que
256
+ // explica, meses depois, por que aquela function estava ali — e por que ela deixou de estar.
257
+ for (const entry of sharedWanted)
258
+ owners.set(entry.name, entry.serves.join('+'));
259
+ const applied = parseAppliedFunctions(options.environment.backend.functions);
260
+ const appliedNames = new Set(applied.map((entry) => entry.name));
261
+ const classification = classifyApplied({
262
+ applied,
263
+ wanted: wantedFunctions,
264
+ baseline: manifest.baselineFunctions ?? [],
265
+ unresolved,
266
+ });
267
+ // Os do diretório comum entram: são eles que as functions comuns servidas carregam, e um parâmetro
268
+ // que ninguém acompanha é um valor que nunca chega à function que o lê.
269
+ const trackedParameters = [...new Set([
270
+ ...claimedParameters(valid),
271
+ ...(office.sources.find((source) => source.shared)?.parameters ?? []).map((p) => p.name),
272
+ ])].sort();
124
273
  // A raiz, e não o `install/`: a fonte é o arquivo que o escritório edita ao lado do `microteia.yaml`.
125
274
  const filledParameters = await readBackendEnv(dirname(options.environmentFile), projectId);
126
275
  const recordedDigests = parseParameterState(options.environment.backend.parameters);
@@ -137,13 +286,47 @@ export const provisionBackend = async (options) => {
137
286
  // instalação fica sem nenhum comando que o aplique, e a feature falha no runtime.
138
287
  const changed = changedParameters(recordedDigests, currentDigests, trackedParameters);
139
288
  const staleFunctions = new Set(functionsDeclaringParameters(valid, changed)
140
- .filter((name) => wantedFunctions.includes(name) && alreadyApplied.includes(name)));
141
- // Uma pergunta só ao projeto, e duas decisões a partir dela: o que ele já roda, e qual cópia ficou
142
- // numa região abandonada.
143
- const deployedFunctions = await listDeployedFunctions(projectId);
289
+ .filter((name) => wantedFunctions.includes(name) && appliedNames.has(name)));
290
+ /**
291
+ * A comum que agora sobe pelo provisionamento também precisa voltar à fila quando um parâmetro muda.
292
+ * Antes ela subia a cada `deploy`, e isso mascarava a pergunta.
293
+ *
294
+ * A granularidade é o DIRETÓRIO, e não a function: `functions/` declara os `defineString` num conjunto
295
+ * só, sem dizer qual function lê qual. Redeployar todas as comuns servidas quando um parâmetro do
296
+ * diretório muda erra para o lado de fazer o que é devido — o inverso deixaria uma delas rodando com o
297
+ * valor antigo, que é o defeito que este mecanismo existe para fechar.
298
+ */
299
+ const sharedSource = office.sources.find((source) => source.shared);
300
+ const sharedParameters = new Set((sharedSource?.parameters ?? []).map((parameter) => parameter.name));
301
+ if (changed.some((name) => sharedParameters.has(name))) {
302
+ for (const entry of sharedWanted) {
303
+ if (appliedNames.has(entry.name))
304
+ staleFunctions.add(entry.name);
305
+ }
306
+ }
307
+ /**
308
+ * Uma pergunta só ao projeto, e duas decisões a partir dela: o que ele já roda, e qual cópia ficou
309
+ * numa região abandonada. Perguntar duas vezes não é caro, é ambíguo — as duas respostas podem
310
+ * divergir, e aí a decisão passa a depender de qual delas o código olhou.
311
+ *
312
+ * Ela é feita SEMPRE, e a versão anterior a pulava quando não havia function aplicada a conferir —
313
+ * economia que custava exatamente o caso que mais importa. É a instalação sem extensão nenhuma que
314
+ * precisa da pergunta: é ali que mora a function de uma extensão que saiu, e sem perguntar ao projeto
315
+ * a resposta é o silêncio. A ida vale segundos; a alternativa é uma callable órfã que nenhum comando
316
+ * menciona.
317
+ */
318
+ let asked;
319
+ const projectFunctions = async () => {
320
+ if (asked === undefined)
321
+ asked = await listDeployedFunctions(projectId);
322
+ return asked;
323
+ };
144
324
  // O que a lista de nomes nunca respondeu: a function aplicada que sumiu do projeto, e a que segue
145
325
  // lá carregando o carimbo de um código anterior. As duas voltam para a fila.
146
- const outdated = outdatedByStamp(wantedFunctions.filter((name) => alreadyApplied.includes(name)), deployedFunctions, region, options.refreshedStamps);
326
+ const appliedAndWanted = wantedFunctions.filter((name) => appliedNames.has(name));
327
+ const outdated = appliedAndWanted.length === 0
328
+ ? { absent: [], stale: [] }
329
+ : outdatedByStamp(appliedAndWanted, await projectFunctions(), region, options.refreshedStamps);
147
330
  for (const name of [...outdated.absent, ...outdated.stale])
148
331
  staleFunctions.add(name);
149
332
  if (outdated.absent.length > 0) {
@@ -180,9 +363,31 @@ export const provisionBackend = async (options) => {
180
363
  info(' Cloud Function at ITS deploy, so a function already running keeps the previous one.');
181
364
  blank();
182
365
  }
183
- const toDeploy = wantedFunctions.filter((name) => options.all === true || !alreadyApplied.includes(name) || staleFunctions.has(name));
366
+ const queued = wantedFunctions.filter((name) => options.all === true || !appliedNames.has(name) || staleFunctions.has(name));
367
+ /**
368
+ * A recusa do portão, cobrada de quem está na fila — e só dela.
369
+ *
370
+ * Ela sai da fila em vez de derrubar o comando, e as duas metades disso importam. Sair da fila é o que
371
+ * impede a function de ir para o ar agindo sobre escritório que não a instalou, que é o defeito
372
+ * inteiro. Não derrubar é o que deixa o resto acontecer: as outras extensões recebem o backend delas,
373
+ * os índices convergem, e `--prune` apaga a órfã. O comando termina em erro por causa dela, no fim,
374
+ * quando já não há nada a atrapalhar.
375
+ */
376
+ const refused = queued.filter((name) => gateFaultBy.has(name));
377
+ const toDeploy = queued.filter((name) => !gateFaultBy.has(name));
378
+ if (refused.length > 0) {
379
+ blank();
380
+ failure(`${refused.length} function(s) are needed by an installed extension and cannot go up:`);
381
+ bullets(refused.map((name) => gateFaultBy.get(name) ?? name));
382
+ info(' they are the only ones held back. Fix the guard and run "microteia provision" again.');
383
+ outcome.failed.push(...refused);
384
+ }
184
385
  if (toDeploy.length === 0) {
185
- success(`the ${wantedFunctions.length} function(s) these extensions need are already deployed`);
386
+ // Anunciar "as 0 functions já estão deployadas" seria o preço de ter tirado o desvio, e não é
387
+ // preciso pagá-lo: quando não há exigência nenhuma, quem já falou foi a linha lá em cima.
388
+ if (wantedFunctions.length > 0 && refused.length === 0) {
389
+ success(`the ${wantedFunctions.length} function(s) these extensions need are already deployed`);
390
+ }
186
391
  }
187
392
  else {
188
393
  // A cópia que o `firebase-functions` lê é atualizada AGORA, e não só no `deploy`: este comando é o
@@ -199,18 +404,18 @@ export const provisionBackend = async (options) => {
199
404
  }
200
405
  // A extensão que mudou de região deixa a cópia antiga para trás igual ao kernel, e ali o efeito
201
406
  // é o mesmo: deploy recusado em toda tentativa, e gatilho disparando em duas regiões.
202
- await clearRegionOrphans(projectId, toDeploy, region, options.prune, deployedFunctions);
407
+ await clearRegionOrphans(projectId, toDeploy, region, options.prune, await projectFunctions());
203
408
  info(` deploying ${toDeploy.length} function(s), one at a time…`);
204
409
  const report = await deployFunctionsOneByOne(toDeploy, {
205
410
  projectId, cwd: install, delaySeconds, force: false,
206
411
  });
207
412
  outcome.deployed = report.deployed;
208
- outcome.failed = report.failed.map((entry) => entry.name);
413
+ outcome.failed.push(...report.failed.map((entry) => entry.name));
209
414
  reportFailures(report.failed);
210
415
  reportNotices(report.notices);
211
416
  await checkNewCallables([...(manifest.callables ?? []), ...office.callables], report.deployed, projectId, region);
212
417
  }
213
- const deployedNow = new Set([...alreadyApplied, ...outcome.deployed]);
418
+ const deployedNow = new Set([...appliedNames, ...outcome.deployed]);
214
419
  for (const claim of valid) {
215
420
  const satisfied = (claim.backend.functions ?? []).every((name) => deployedNow.has(name));
216
421
  if (satisfied) {
@@ -220,14 +425,49 @@ export const provisionBackend = async (options) => {
220
425
  outcome.pending.push(claim);
221
426
  }
222
427
  }
223
- const orphans = alreadyApplied.filter((name) => !wantedFunctions.includes(name));
224
- outcome.orphans = orphans;
225
- const remaining = await settleOrphans(options, orphans);
428
+ /**
429
+ * O que o PROJETO roda, cruzado com o que esta instalação consegue justificar.
430
+ *
431
+ * O registro respondia sozinho por essa pergunta, e é um registro que se perde — e quando se perde,
432
+ * some com ele a única memória de que aquela function existe. Perguntar ao projeto é o que fecha o
433
+ * buraco: uma callable de extensão desinstalada, no ar, publicamente invocável, com o
434
+ * `backend.functions` do `microteia.yaml` vazio, não era mencionada por comando nenhum.
435
+ *
436
+ * O limite está em `straysInProject`, e é o que torna isto seguro: só vira órfã o nome que ESTA
437
+ * instalação sabe produzir. O que a órfã ganha aqui, além da remoção, é entrar no registro quando não
438
+ * for removida — a partir da próxima execução ela é conhecida mesmo que o código-fonte dela saia do
439
+ * disco.
440
+ */
441
+ const running = await projectFunctions();
442
+ const stray = straysInProject({
443
+ // A região é o filtro, e o ESTADO não é. Uma cópia em região abandonada tem endereço próprio
444
+ // (`clearRegionOrphans`); já o estado só decide se dá para comparar carimbo — para existir no
445
+ // projeto, basta estar na listagem. E o `functions:list` nem sempre traz o campo: exigir `ACTIVE`
446
+ // deixaria a varredura cega em silêncio no dia em que ele viesse vazio.
447
+ running: (running ?? []).filter((entry) => entry.region === region).map((entry) => entry.name),
448
+ known: [...manifest.functions, ...office.all],
449
+ justified: [
450
+ ...wantedFunctions,
451
+ // A baseline do PACOTE, com o mesmo fallback que o `deploy` usa para decidir o que subir. Um
452
+ // `?? []` aqui faria toda function da plataforma virar órfã num pacote sem o campo.
453
+ ...(manifest.baselineFunctions ?? manifest.functions),
454
+ ...office.unserved,
455
+ ...unresolved.keys(),
456
+ ],
457
+ recorded: applied.map((entry) => entry.name),
458
+ });
459
+ reportStrays(stray, running);
460
+ reportPreserved(classification.preserved);
461
+ const orphans = [...classification.orphans, ...stray.orphans];
462
+ outcome.orphans = orphans.map((entry) => entry.name);
463
+ const remaining = await settleOrphans(options, orphans, survey);
226
464
  await recordBackendState(options, appliedAfterRun({
227
- alreadyApplied,
465
+ applied,
228
466
  wanted: wantedFunctions,
229
467
  deployed: outcome.deployed,
230
468
  keptOrphans: remaining,
469
+ preserved: classification.preserved.map((item) => item.entry),
470
+ owners,
231
471
  }), appliedParameterState({
232
472
  recorded: recordedDigests,
233
473
  current: currentDigests,
@@ -236,8 +476,37 @@ export const provisionBackend = async (options) => {
236
476
  claims: valid,
237
477
  }), settled.declared);
238
478
  reportOrphanIndexes(projectId, settled.orphans, options.prune);
479
+ await publishResidue(projectId, remaining, settled.orphans, survey);
239
480
  return outcome;
240
481
  };
482
+ /**
483
+ * O que sobrou, devolvido ao banco para a aplicação poder mostrá-lo.
484
+ *
485
+ * A lista é a que sobra DEPOIS do `--prune`: o que ele apagou saiu, e o que ele não conseguiu apagar
486
+ * fica — é o estado do projeto agora, e não o do começo da execução.
487
+ *
488
+ * Falhar aqui não derruba o comando, e a exceção é justificada: as functions e os índices já foram
489
+ * aplicados, e o que ficou de fazer era um RELATO. Recusar o `provision` inteiro por causa dele mandaria
490
+ * quem instala rodar de novo um comando que já terminou o trabalho. Mas o aviso é dito em voz alta —
491
+ * calar deixaria a tela silenciosa parecendo "não há resíduo", que é a afirmação oposta.
492
+ */
493
+ const publishResidue = async (projectId, functions, indexes, survey) => {
494
+ try {
495
+ await writeBackendResidue(projectId, {
496
+ scannedAt: new Date(),
497
+ tenants: survey.tenants,
498
+ extensions: survey.extensions,
499
+ functions: functions.map((entry) => ({ name: entry.name, owner: entry.owner ?? '' })),
500
+ indexes: indexes.map(describeIndex),
501
+ });
502
+ }
503
+ catch (error) {
504
+ warn('could not record what is left over where the application can show it:');
505
+ info(` ${error instanceof Error ? error.message : String(error)}`);
506
+ info(' the artifacts above were applied. What did not happen is the Extensions screen learning');
507
+ info(' about the leftovers — it keeps showing what the previous run recorded.');
508
+ }
509
+ };
241
510
  /**
242
511
  * O que o projeto tem de declarar de índice composto, e o que sobrou de uma extensão que saiu.
243
512
  *
@@ -320,34 +589,110 @@ const stampApplied = async (projectId, all, claim) => {
320
589
  success(`${claim.name} is ready in ${projectId}`);
321
590
  };
322
591
  /**
323
- * Function que nenhuma extensão instalada exige mais. Apagar é destrutivo e irreversível, então o
324
- * padrão RELATA e só `--prune` remove — e o que foi removido sai da lista de aplicadas.
592
+ * O que ninguém exige e mesmo assim NÃO é órfã.
593
+ *
594
+ * Relatar é o ponto. As duas causas são configuração errada de um lado ou do outro, e o silêncio sobre
595
+ * elas faria a próxima pessoa a abrir o `microteia.yaml` concluir que a lista está suja — e apagar à
596
+ * mão exatamente o que estas linhas protegem.
597
+ */
598
+ const reportPreserved = (preserved) => {
599
+ const baseline = preserved.filter((item) => item.reason === 'baseline');
600
+ const unresolved = preserved.filter((item) => item.reason === 'unresolved');
601
+ if (baseline.length > 0) {
602
+ blank();
603
+ warn(`${baseline.length} function(s) in this installation's record belong to the platform:`);
604
+ bullets(baseline.map((item) => describeApplied(item.entry)));
605
+ info(' every "microteia deploy" puts them up, extension or no extension, so nothing here removes');
606
+ info(' them — not even --prune.');
607
+ }
608
+ if (unresolved.length > 0) {
609
+ blank();
610
+ warn(`${unresolved.length} function(s) belong to an extension whose declaration did not resolve:`);
611
+ bullets(unresolved.map((item) => `${item.entry.name} — claimed by ${item.claimant}`));
612
+ info(' the extension is still installed, so they are not orphans and nothing removes them.');
613
+ info(' Fix what is reported above and run "microteia provision" again.');
614
+ }
615
+ };
616
+ /**
617
+ * O que a varredura do projeto achou, e que o registro não conhecia.
618
+ *
619
+ * As duas listas são ditas porque respondem a perguntas diferentes de quem lê. A órfã é uma decisão a
620
+ * tomar — ela vai para `settleOrphans` logo abaixo, e sai com `--prune`. O estranho não é decisão
621
+ * nenhuma: é um aviso de que o projeto Firebase tem inquilino que a Microteia não pôs ali, e dizer que
622
+ * nada o toca vale mais do que o silêncio, porque o silêncio aqui parece "não há mais nada".
623
+ */
624
+ const reportStrays = (stray, running) => {
625
+ if (running === null) {
626
+ blank();
627
+ warn('could not list what this project is running, so nothing was compared against it.');
628
+ info(' a function left behind by an extension that is gone would not be found this run. The');
629
+ info(' account needs permission to run "firebase functions:list".');
630
+ return;
631
+ }
632
+ if (stray.orphans.length > 0) {
633
+ blank();
634
+ info(` ${stray.orphans.length} function(s) are running in this project and this installation's`);
635
+ info(' record does not mention them:');
636
+ bullets(stray.orphans.map((entry) => entry.name));
637
+ info(' this installation is what produces them, and nothing installed needs them — so they are');
638
+ info(' treated as orphans below. A record is lost by a re-init, a restored backup, or a Console');
639
+ info(' older than the field; the function it forgot stays in the air either way.');
640
+ }
641
+ if (stray.strangers.length > 0) {
642
+ blank();
643
+ info(` ${stray.strangers.length} function(s) run in this project and did not come from this`);
644
+ info(' installation:');
645
+ bullets(stray.strangers);
646
+ info(' nothing here touches them — not even --prune. A Firebase project can be shared, and');
647
+ info(' deleting what Microteia did not put there is not this command\'s call.');
648
+ info(' A function of yours lands here once its source is gone from this installation: run');
649
+ info(' "microteia provision" BEFORE deleting extensions/<folder>/, and it is recognized.');
650
+ }
651
+ };
652
+ /**
653
+ * Function que nenhuma extensão instalada exige mais.
654
+ *
655
+ * A CERTEZA vem antes da remoção, e é dita em voz alta: a Cloud Function é do PROJETO e a instalação é
656
+ * do ESCRITÓRIO, então "ninguém mais exige" é uma afirmação sobre TODOS os escritórios do projeto, e
657
+ * não sobre o último que foi lido. A varredura é completa ou não existe — `listDocuments` pagina até o
658
+ * fim e qualquer leitura recusada derruba o comando lá em cima —, e é o número dito aqui que permite a
659
+ * quem lê conferir a afirmação em vez de acreditar nela.
660
+ *
661
+ * Apagar é destrutivo e irreversível, então o padrão RELATA e só `--prune` remove — e o que foi
662
+ * removido sai da lista de aplicadas.
325
663
  */
326
- const settleOrphans = async (options, orphans) => {
664
+ const settleOrphans = async (options, orphans, survey) => {
327
665
  if (orphans.length === 0)
328
666
  return [];
329
667
  blank();
668
+ warn(`${orphans.length} function(s) are deployed and no installed extension needs them:`);
669
+ bullets(orphans.map(describeApplied));
670
+ info(` read from every office in this project: ${survey.tenants} office(s), `
671
+ + `${survey.extensions} installed extension(s).`);
330
672
  if (!options.prune) {
331
- warn(`${orphans.length} function(s) are deployed but no installed extension needs them:`);
332
- bullets(orphans);
333
- info(' they keep working and cost nothing while idle. To remove them:');
673
+ // O custo mudou de descrição porque a descrição estava errada. Uma callable ociosa não custa nada;
674
+ // um gatilho de Firestore não fica ocioso — ele segue disparando a cada gravação do escritório,
675
+ // rodando o código de uma extensão que ninguém tem mais, e é esse efeito que faz a remoção valer
676
+ // a pena antes de "algum dia".
677
+ info(' a callable just sits there. A Firestore trigger does not: it keeps firing on every write,');
678
+ info(' running the code of an extension nobody has any more. To remove them:');
334
679
  bullets(['microteia provision --prune']);
335
680
  blank();
336
681
  return orphans;
337
682
  }
338
683
  info(` deleting ${orphans.length} orphan function(s)…`);
339
- const deleted = [];
340
- for (const name of orphans) {
684
+ const deleted = new Set();
685
+ for (const orphan of orphans) {
341
686
  try {
342
- await run(FIREBASE_BIN, ['functions:delete', name, '--project', options.projectId, '--region', options.region, '--force'], { cwd: options.install });
343
- deleted.push(name);
687
+ await run(FIREBASE_BIN, ['functions:delete', orphan.name, '--project', options.projectId, '--region', options.region, '--force'], { cwd: options.install });
688
+ deleted.add(orphan.name);
344
689
  }
345
690
  catch {
346
- warn(`could not delete ${name}; it stays recorded as deployed`);
691
+ warn(`could not delete ${orphan.name}; it stays recorded as deployed`);
347
692
  }
348
693
  }
349
- success(`${deleted.length} orphan function(s) removed`);
350
- return orphans.filter((name) => !deleted.includes(name));
694
+ success(`${deleted.size} orphan function(s) removed`);
695
+ return orphans.filter((orphan) => !deleted.has(orphan.name));
351
696
  };
352
697
  /**
353
698
  * Índice órfão é RELATADO, nunca apagado às cegas: o `firebase deploy` não remove índice por conta, e a
@@ -380,16 +725,6 @@ const reportOrphanIndexes = (projectId, orphans, prune) => {
380
725
  ]);
381
726
  blank();
382
727
  };
383
- /**
384
- * A lista aplicada CONVERGE para o que ainda é exigido: o que já estava e continua sendo pedido, mais
385
- * o que subiu agora, mais o órfão que não foi removido. Reescrevê-la só com o que subiu NESTA execução
386
- * a esvaziava na segunda passada — e a terceira redeployava tudo, achando que nada havia sido feito.
387
- */
388
- export const appliedAfterRun = (input) => [...new Set([
389
- ...input.alreadyApplied.filter((name) => input.wanted.includes(name)),
390
- ...input.deployed,
391
- ...input.keptOrphans,
392
- ])].sort();
393
728
  /**
394
729
  * A impressão digital do parâmetro só avança quando TODAS as functions que o carregam subiram. Gravar
395
730
  * antes disso é o que transformaria uma falha de deploy em silêncio permanente: o valor novo ficaria
@@ -414,7 +749,7 @@ export const appliedParameterState = (input) => {
414
749
  }
415
750
  return next;
416
751
  };
417
- const recordBackendState = async (options, names, parameters, indexes) => {
752
+ const recordBackendState = async (options, applied, parameters, indexes) => {
418
753
  const state = serializeIndexState(indexes);
419
754
  /**
420
755
  * Índice que a codificação não representa sem ambiguidade fica de fora COM AVISO, em vez de voltar do
@@ -431,7 +766,7 @@ const recordBackendState = async (options, names, parameters, indexes) => {
431
766
  await writeEnvironment(options.environmentFile, {
432
767
  ...options.environment,
433
768
  backend: {
434
- functions: names.length === 0 ? null : names.join(','),
769
+ functions: serializeAppliedFunctions(applied),
435
770
  parameters: serializeParameterState(parameters),
436
771
  indexes: state.value,
437
772
  },