@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.
@@ -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, misgatedTriggers, singleClaimants, ungatedTriggerProblems, unguardedCallableProblems, unscopedTriggerProblems, } from './functionGate.js';
21
+ import { appliedAfterRun, classifyApplied, describeApplied, parseAppliedFunctions, serializeAppliedFunctions, } 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,92 @@ 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 gatilho sem portão é RELATADO aqui e RECUSADO na montagem (`functionProblems`), e a assimetria é
144
+ * deliberada: este é o único comando capaz de RETIRAR uma function do projeto. Recusar aqui trancaria
145
+ * `--prune` — o comando que apaga a órfã — por causa de uma fonte que ele não precisa ler. A recusa
146
+ * acontece onde a fonte de fato entra no pacote, e aí ela não tranca a saída de ninguém.
147
+ */
148
+ const ungated = [
149
+ ...ungatedTriggerProblems(office.sources),
150
+ ...unscopedTriggerProblems(office.sources),
151
+ ...unguardedCallableProblems(office.sources),
152
+ ];
153
+ if (ungated.length > 0) {
154
+ warn(`${ungated.length} function(s) written in this installation act outside any office's`
155
+ + ' installation:');
156
+ bullets(ungated);
157
+ info(' they are refused the moment something tries to deploy them. Until then they keep acting on');
158
+ info(' every office in this project.');
159
+ }
160
+ /**
161
+ * A comum SEM declaração de quem serve. Ela continua subindo no `deploy`, sempre, e nada a retira — é
162
+ * o comportamento anterior, mantido para não quebrar instalação nenhuma.
163
+ *
164
+ * O que muda é que ele deixa de ser invisível. Era esse o defeito: quem herdasse a instalação não tinha
165
+ * como saber que aquelas functions estavam fora do mecanismo de retirada, e a única forma de descobrir
166
+ * era um gatilho disparando anos depois de a última extensão que o usava ter saído.
167
+ */
168
+ if (office.unserved.length > 0) {
169
+ blank();
170
+ info(` ${office.unserved.length} shared function(s) do not say which extensions they serve:`);
171
+ bullets(office.unserved);
172
+ info(' they go up with every "microteia deploy" and nothing here withdraws them — not even --prune.');
173
+ info(' Name the extensions each one exists for in "microteia" -> "serves" in functions/package.json,');
174
+ info(' and they are deployed only while one of those is installed, and removed when none is.');
175
+ }
176
+ if (misgated.length > 0) {
177
+ warn('some extensions gate a trigger for an extension id that is not their own:');
178
+ bullets(misgated.map(describeMisgated));
179
+ info(' it is what a copied function leaves behind. The trigger deploys, fires on every write, and');
180
+ info(' the handler never runs — the gate asks about an extension nobody installed. Fix the id in');
181
+ info(' the forInstalledExtension call.');
182
+ }
183
+ if (problems.length > 0) {
96
184
  for (const claim of claims) {
97
185
  if (problems.some((problem) => problem.includes(claim.pluginId)))
98
186
  rejected.add(claim.pluginId);
99
187
  }
188
+ // O dono de um portão errado é pendurado por IDENTIDADE, e não por o id dele aparecer no texto: a
189
+ // mensagem cita DOIS ids, e casar por substring penduraria também a extensão cujo id foi copiado.
190
+ for (const entry of misgated)
191
+ rejected.add(entry.owner);
100
192
  }
101
193
  const valid = uniqueByPlugin(claims.filter((claim) => !rejected.has(claim.pluginId)));
102
194
  /**
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.
195
+ * O nome que uma reivindicação RECUSADA carrega. Ele não entra em `wantedFunctions` — não há como
196
+ * deployá-lo —, mas também não pode virar órfão: a extensão que o pede continua instalada, e o que
197
+ * falhou foi resolver a declaração dela. Sem esta lista, um export renomeado em
198
+ * `extensions/<x>/functions/index.ts` fazia `--prune` apagar a function de uma extensão viva.
107
199
  */
200
+ const unresolved = new Map();
201
+ for (const claim of claims) {
202
+ if (!rejected.has(claim.pluginId))
203
+ continue;
204
+ for (const name of claim.backend.functions ?? [])
205
+ unresolved.set(name, claim.pluginId);
206
+ }
108
207
  if (valid.length === 0) {
109
208
  info(' no installed extension asks for backend artifacts');
110
209
  }
@@ -113,14 +212,60 @@ export const provisionBackend = async (options) => {
113
212
  bullets(valid.map((claim) => `${claim.name} ${claim.version} (${claim.pluginId})`));
114
213
  }
115
214
  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;
215
+ /**
216
+ * NÃO existe mais um desvio de "nenhuma extensão exige nada" aqui — e é a correção do defeito que
217
+ * este comando tinha justamente no caso mais comum. Desinstalar a ÚLTIMA extensão com backend caía
218
+ * naquele desvio, e o desvio devolvia antes do cálculo de órfã: a Cloud Function dela continuava no
219
+ * ar para sempre, `--prune` não a alcançava, e a lista de aplicadas era regravada intacta. O sintoma
220
+ * era um gatilho `onDocumentWritten` de uma extensão que ninguém tem mais, disparando sobre os dados
221
+ * do escritório a cada gravação.
222
+ *
223
+ * O índice já havia sido salvo dessa mesma armadilha — foi por isso que `settleIndexes` subiu para
224
+ * antes do desvio. A function ficou para trás, e a lição é que o desvio é que estava errado: o que
225
+ * ele pulava não era "trabalho inútil", era a retirada.
226
+ *
227
+ * O que sobrou dele são duas guardas pontuais mais abaixo: não perguntar ao projeto o que ele roda
228
+ * quando não há nada a conferir, e não anunciar que "as 0 functions já estão deployadas".
229
+ */
230
+ /**
231
+ * A function COMUM que declara quem serve entra aqui, e é o que a tira da invisibilidade.
232
+ *
233
+ * Ela não tem dono que possa reivindicá-la — nenhuma extensão pode —, então ela ficava fora do
234
+ * mecanismo por inteiro: nunca entrava no registro de aplicadas, nunca virava órfã, nunca era
235
+ * relatada, e o `--prune` não a alcançava. Com a declaração, a pergunta passa a ter resposta: ela é
236
+ * devida enquanto ALGUMA das extensões que ela serve estiver instalada em algum escritório do projeto.
237
+ *
238
+ * "Basta uma" é a única regra possível: a function existe porque mais de uma extensão precisa dela.
239
+ * Exigir todas faria desinstalar uma desligar o backend das outras.
240
+ */
241
+ const sharedWanted = office.served.filter((entry) => entry.serves.some((id) => survey.installedIds.has(id)));
242
+ const sharedWantedNames = new Set(sharedWanted.map((entry) => entry.name));
243
+ const wantedFunctions = deployable.filter((name) => sharedWantedNames.has(name)
244
+ || valid.some((claim) => (claim.backend.functions ?? []).includes(name)));
245
+ /** Quem reivindica cada nome AGORA — é com este dono que o registro é reconciliado. */
246
+ const owners = new Map();
247
+ for (const claim of valid) {
248
+ for (const name of claim.backend.functions ?? [])
249
+ owners.set(name, claim.pluginId);
120
250
  }
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);
251
+ // A comum tem VÁRIOS donos, e o registro guarda a lista declarada em vez de eleger um deles: é ela que
252
+ // explica, meses depois, por que aquela function estava ali — e por que ela deixou de estar.
253
+ for (const entry of sharedWanted)
254
+ owners.set(entry.name, entry.serves.join('+'));
255
+ const applied = parseAppliedFunctions(options.environment.backend.functions);
256
+ const appliedNames = new Set(applied.map((entry) => entry.name));
257
+ const classification = classifyApplied({
258
+ applied,
259
+ wanted: wantedFunctions,
260
+ baseline: manifest.baselineFunctions ?? [],
261
+ unresolved,
262
+ });
263
+ // Os do diretório comum entram: são eles que as functions comuns servidas carregam, e um parâmetro
264
+ // que ninguém acompanha é um valor que nunca chega à function que o lê.
265
+ const trackedParameters = [...new Set([
266
+ ...claimedParameters(valid),
267
+ ...(office.sources.find((source) => source.shared)?.parameters ?? []).map((p) => p.name),
268
+ ])].sort();
124
269
  // A raiz, e não o `install/`: a fonte é o arquivo que o escritório edita ao lado do `microteia.yaml`.
125
270
  const filledParameters = await readBackendEnv(dirname(options.environmentFile), projectId);
126
271
  const recordedDigests = parseParameterState(options.environment.backend.parameters);
@@ -137,13 +282,46 @@ export const provisionBackend = async (options) => {
137
282
  // instalação fica sem nenhum comando que o aplique, e a feature falha no runtime.
138
283
  const changed = changedParameters(recordedDigests, currentDigests, trackedParameters);
139
284
  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);
285
+ .filter((name) => wantedFunctions.includes(name) && appliedNames.has(name)));
286
+ /**
287
+ * A comum que agora sobe pelo provisionamento também precisa voltar à fila quando um parâmetro muda.
288
+ * Antes ela subia a cada `deploy`, e isso mascarava a pergunta.
289
+ *
290
+ * A granularidade é o DIRETÓRIO, e não a function: `functions/` declara os `defineString` num conjunto
291
+ * só, sem dizer qual function lê qual. Redeployar todas as comuns servidas quando um parâmetro do
292
+ * diretório muda erra para o lado de fazer o que é devido — o inverso deixaria uma delas rodando com o
293
+ * valor antigo, que é o defeito que este mecanismo existe para fechar.
294
+ */
295
+ const sharedSource = office.sources.find((source) => source.shared);
296
+ const sharedParameters = new Set((sharedSource?.parameters ?? []).map((parameter) => parameter.name));
297
+ if (changed.some((name) => sharedParameters.has(name))) {
298
+ for (const entry of sharedWanted) {
299
+ if (appliedNames.has(entry.name))
300
+ staleFunctions.add(entry.name);
301
+ }
302
+ }
303
+ /**
304
+ * Uma pergunta só ao projeto, e duas decisões a partir dela: o que ele já roda, e qual cópia ficou
305
+ * numa região abandonada. Perguntar duas vezes não é caro, é ambíguo — as duas respostas podem
306
+ * divergir, e aí a decisão passa a depender de qual delas o código olhou.
307
+ *
308
+ * E ela é feita só quando há o que decidir. Sem o desvio lá em cima, este caminho passou a ser
309
+ * percorrido também pelo `provision` que roda quando não sobrou extensão nenhuma — e ali não há
310
+ * function para conferir nem para subir, então um `functions:list` seria pagar segundos por uma
311
+ * resposta que ninguém lê.
312
+ */
313
+ let asked;
314
+ const projectFunctions = async () => {
315
+ if (asked === undefined)
316
+ asked = await listDeployedFunctions(projectId);
317
+ return asked;
318
+ };
144
319
  // O que a lista de nomes nunca respondeu: a function aplicada que sumiu do projeto, e a que segue
145
320
  // 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);
321
+ const appliedAndWanted = wantedFunctions.filter((name) => appliedNames.has(name));
322
+ const outdated = appliedAndWanted.length === 0
323
+ ? { absent: [], stale: [] }
324
+ : outdatedByStamp(appliedAndWanted, await projectFunctions(), region, options.refreshedStamps);
147
325
  for (const name of [...outdated.absent, ...outdated.stale])
148
326
  staleFunctions.add(name);
149
327
  if (outdated.absent.length > 0) {
@@ -180,9 +358,13 @@ export const provisionBackend = async (options) => {
180
358
  info(' Cloud Function at ITS deploy, so a function already running keeps the previous one.');
181
359
  blank();
182
360
  }
183
- const toDeploy = wantedFunctions.filter((name) => options.all === true || !alreadyApplied.includes(name) || staleFunctions.has(name));
361
+ const toDeploy = wantedFunctions.filter((name) => options.all === true || !appliedNames.has(name) || staleFunctions.has(name));
184
362
  if (toDeploy.length === 0) {
185
- success(`the ${wantedFunctions.length} function(s) these extensions need are already deployed`);
363
+ // Anunciar "as 0 functions já estão deployadas" seria o preço de ter tirado o desvio, e não é
364
+ // preciso pagá-lo: quando não há exigência nenhuma, quem já falou foi a linha lá em cima.
365
+ if (wantedFunctions.length > 0) {
366
+ success(`the ${wantedFunctions.length} function(s) these extensions need are already deployed`);
367
+ }
186
368
  }
187
369
  else {
188
370
  // A cópia que o `firebase-functions` lê é atualizada AGORA, e não só no `deploy`: este comando é o
@@ -199,7 +381,7 @@ export const provisionBackend = async (options) => {
199
381
  }
200
382
  // A extensão que mudou de região deixa a cópia antiga para trás igual ao kernel, e ali o efeito
201
383
  // é o mesmo: deploy recusado em toda tentativa, e gatilho disparando em duas regiões.
202
- await clearRegionOrphans(projectId, toDeploy, region, options.prune, deployedFunctions);
384
+ await clearRegionOrphans(projectId, toDeploy, region, options.prune, await projectFunctions());
203
385
  info(` deploying ${toDeploy.length} function(s), one at a time…`);
204
386
  const report = await deployFunctionsOneByOne(toDeploy, {
205
387
  projectId, cwd: install, delaySeconds, force: false,
@@ -210,7 +392,7 @@ export const provisionBackend = async (options) => {
210
392
  reportNotices(report.notices);
211
393
  await checkNewCallables([...(manifest.callables ?? []), ...office.callables], report.deployed, projectId, region);
212
394
  }
213
- const deployedNow = new Set([...alreadyApplied, ...outcome.deployed]);
395
+ const deployedNow = new Set([...appliedNames, ...outcome.deployed]);
214
396
  for (const claim of valid) {
215
397
  const satisfied = (claim.backend.functions ?? []).every((name) => deployedNow.has(name));
216
398
  if (satisfied) {
@@ -220,14 +402,16 @@ export const provisionBackend = async (options) => {
220
402
  outcome.pending.push(claim);
221
403
  }
222
404
  }
223
- const orphans = alreadyApplied.filter((name) => !wantedFunctions.includes(name));
224
- outcome.orphans = orphans;
225
- const remaining = await settleOrphans(options, orphans);
405
+ reportPreserved(classification.preserved);
406
+ outcome.orphans = classification.orphans.map((entry) => entry.name);
407
+ const remaining = await settleOrphans(options, classification.orphans, survey);
226
408
  await recordBackendState(options, appliedAfterRun({
227
- alreadyApplied,
409
+ applied,
228
410
  wanted: wantedFunctions,
229
411
  deployed: outcome.deployed,
230
412
  keptOrphans: remaining,
413
+ preserved: classification.preserved.map((item) => item.entry),
414
+ owners,
231
415
  }), appliedParameterState({
232
416
  recorded: recordedDigests,
233
417
  current: currentDigests,
@@ -236,8 +420,37 @@ export const provisionBackend = async (options) => {
236
420
  claims: valid,
237
421
  }), settled.declared);
238
422
  reportOrphanIndexes(projectId, settled.orphans, options.prune);
423
+ await publishResidue(projectId, remaining, settled.orphans, survey);
239
424
  return outcome;
240
425
  };
426
+ /**
427
+ * O que sobrou, devolvido ao banco para a aplicação poder mostrá-lo.
428
+ *
429
+ * A lista é a que sobra DEPOIS do `--prune`: o que ele apagou saiu, e o que ele não conseguiu apagar
430
+ * fica — é o estado do projeto agora, e não o do começo da execução.
431
+ *
432
+ * Falhar aqui não derruba o comando, e a exceção é justificada: as functions e os índices já foram
433
+ * aplicados, e o que ficou de fazer era um RELATO. Recusar o `provision` inteiro por causa dele mandaria
434
+ * quem instala rodar de novo um comando que já terminou o trabalho. Mas o aviso é dito em voz alta —
435
+ * calar deixaria a tela silenciosa parecendo "não há resíduo", que é a afirmação oposta.
436
+ */
437
+ const publishResidue = async (projectId, functions, indexes, survey) => {
438
+ try {
439
+ await writeBackendResidue(projectId, {
440
+ scannedAt: new Date(),
441
+ tenants: survey.tenants,
442
+ extensions: survey.extensions,
443
+ functions: functions.map((entry) => ({ name: entry.name, owner: entry.owner ?? '' })),
444
+ indexes: indexes.map(describeIndex),
445
+ });
446
+ }
447
+ catch (error) {
448
+ warn('could not record what is left over where the application can show it:');
449
+ info(` ${error instanceof Error ? error.message : String(error)}`);
450
+ info(' the artifacts above were applied. What did not happen is the Extensions screen learning');
451
+ info(' about the leftovers — it keeps showing what the previous run recorded.');
452
+ }
453
+ };
241
454
  /**
242
455
  * O que o projeto tem de declarar de índice composto, e o que sobrou de uma extensão que saiu.
243
456
  *
@@ -320,34 +533,74 @@ const stampApplied = async (projectId, all, claim) => {
320
533
  success(`${claim.name} is ready in ${projectId}`);
321
534
  };
322
535
  /**
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.
536
+ * O que ninguém exige e mesmo assim NÃO é órfã.
537
+ *
538
+ * Relatar é o ponto. As duas causas são configuração errada de um lado ou do outro, e o silêncio sobre
539
+ * elas faria a próxima pessoa a abrir o `microteia.yaml` concluir que a lista está suja — e apagar à
540
+ * mão exatamente o que estas linhas protegem.
541
+ */
542
+ const reportPreserved = (preserved) => {
543
+ const baseline = preserved.filter((item) => item.reason === 'baseline');
544
+ const unresolved = preserved.filter((item) => item.reason === 'unresolved');
545
+ if (baseline.length > 0) {
546
+ blank();
547
+ warn(`${baseline.length} function(s) in this installation's record belong to the platform:`);
548
+ bullets(baseline.map((item) => describeApplied(item.entry)));
549
+ info(' every "microteia deploy" puts them up, extension or no extension, so nothing here removes');
550
+ info(' them — not even --prune.');
551
+ }
552
+ if (unresolved.length > 0) {
553
+ blank();
554
+ warn(`${unresolved.length} function(s) belong to an extension whose declaration did not resolve:`);
555
+ bullets(unresolved.map((item) => `${item.entry.name} — claimed by ${item.claimant}`));
556
+ info(' the extension is still installed, so they are not orphans and nothing removes them.');
557
+ info(' Fix what is reported above and run "microteia provision" again.');
558
+ }
559
+ };
560
+ /**
561
+ * Function que nenhuma extensão instalada exige mais.
562
+ *
563
+ * A CERTEZA vem antes da remoção, e é dita em voz alta: a Cloud Function é do PROJETO e a instalação é
564
+ * do ESCRITÓRIO, então "ninguém mais exige" é uma afirmação sobre TODOS os escritórios do projeto, e
565
+ * não sobre o último que foi lido. A varredura é completa ou não existe — `listDocuments` pagina até o
566
+ * fim e qualquer leitura recusada derruba o comando lá em cima —, e é o número dito aqui que permite a
567
+ * quem lê conferir a afirmação em vez de acreditar nela.
568
+ *
569
+ * Apagar é destrutivo e irreversível, então o padrão RELATA e só `--prune` remove — e o que foi
570
+ * removido sai da lista de aplicadas.
325
571
  */
326
- const settleOrphans = async (options, orphans) => {
572
+ const settleOrphans = async (options, orphans, survey) => {
327
573
  if (orphans.length === 0)
328
574
  return [];
329
575
  blank();
576
+ warn(`${orphans.length} function(s) are deployed and no installed extension needs them:`);
577
+ bullets(orphans.map(describeApplied));
578
+ info(` read from every office in this project: ${survey.tenants} office(s), `
579
+ + `${survey.extensions} installed extension(s).`);
330
580
  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:');
581
+ // O custo mudou de descrição porque a descrição estava errada. Uma callable ociosa não custa nada;
582
+ // um gatilho de Firestore não fica ocioso — ele segue disparando a cada gravação do escritório,
583
+ // rodando o código de uma extensão que ninguém tem mais, e é esse efeito que faz a remoção valer
584
+ // a pena antes de "algum dia".
585
+ info(' a callable just sits there. A Firestore trigger does not: it keeps firing on every write,');
586
+ info(' running the code of an extension nobody has any more. To remove them:');
334
587
  bullets(['microteia provision --prune']);
335
588
  blank();
336
589
  return orphans;
337
590
  }
338
591
  info(` deleting ${orphans.length} orphan function(s)…`);
339
- const deleted = [];
340
- for (const name of orphans) {
592
+ const deleted = new Set();
593
+ for (const orphan of orphans) {
341
594
  try {
342
- await run(FIREBASE_BIN, ['functions:delete', name, '--project', options.projectId, '--region', options.region, '--force'], { cwd: options.install });
343
- deleted.push(name);
595
+ await run(FIREBASE_BIN, ['functions:delete', orphan.name, '--project', options.projectId, '--region', options.region, '--force'], { cwd: options.install });
596
+ deleted.add(orphan.name);
344
597
  }
345
598
  catch {
346
- warn(`could not delete ${name}; it stays recorded as deployed`);
599
+ warn(`could not delete ${orphan.name}; it stays recorded as deployed`);
347
600
  }
348
601
  }
349
- success(`${deleted.length} orphan function(s) removed`);
350
- return orphans.filter((name) => !deleted.includes(name));
602
+ success(`${deleted.size} orphan function(s) removed`);
603
+ return orphans.filter((orphan) => !deleted.has(orphan.name));
351
604
  };
352
605
  /**
353
606
  * Índice órfão é RELATADO, nunca apagado às cegas: o `firebase deploy` não remove índice por conta, e a
@@ -380,16 +633,6 @@ const reportOrphanIndexes = (projectId, orphans, prune) => {
380
633
  ]);
381
634
  blank();
382
635
  };
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
636
  /**
394
637
  * A impressão digital do parâmetro só avança quando TODAS as functions que o carregam subiram. Gravar
395
638
  * antes disso é o que transformaria uma falha de deploy em silêncio permanente: o valor novo ficaria
@@ -414,7 +657,7 @@ export const appliedParameterState = (input) => {
414
657
  }
415
658
  return next;
416
659
  };
417
- const recordBackendState = async (options, names, parameters, indexes) => {
660
+ const recordBackendState = async (options, applied, parameters, indexes) => {
418
661
  const state = serializeIndexState(indexes);
419
662
  /**
420
663
  * Índice que a codificação não representa sem ambiguidade fica de fora COM AVISO, em vez de voltar do
@@ -431,7 +674,7 @@ const recordBackendState = async (options, names, parameters, indexes) => {
431
674
  await writeEnvironment(options.environmentFile, {
432
675
  ...options.environment,
433
676
  backend: {
434
- functions: names.length === 0 ? null : names.join(','),
677
+ functions: serializeAppliedFunctions(applied),
435
678
  parameters: serializeParameterState(parameters),
436
679
  indexes: state.value,
437
680
  },
@@ -54,6 +54,33 @@ export declare const baselineIndexesOf: (rootIndexes: FirestoreIndexSpec[], clai
54
54
  * no `provision`, contra o que veio do banco do escritório, que é dado e não código.
55
55
  */
56
56
  export declare const functionClaimProblems: (claims: BackendClaim[], packagedFunctions: string[]) => string[];
57
+ /**
58
+ * Reivindicar uma function que a PLATAFORMA sobe sempre.
59
+ *
60
+ * É conferência de PROVISIONAMENTO, não de empacotamento, e a assimetria é da própria definição: no
61
+ * `pack` a baseline é DERIVADA (`todas − reivindicadas`), então reivindicar é o que tira o nome dela e
62
+ * a pergunta não existe. Do lado de quem instala a baseline já está decidida e viaja no manifesto, e
63
+ * aí a reivindicação de um nome dela é uma contradição com duas consequências, nenhuma visível: o
64
+ * `deploy` sobe a function de qualquer jeito, e a desinstalação da extensão faz `--prune` apagar um
65
+ * gatilho do produto num escritório que não fez nada.
66
+ */
67
+ export declare const baselineClaimProblems: (claims: BackendClaim[], baselineFunctions: string[]) => string[];
68
+ /**
69
+ * Reivindicar uma function COMUM do escritório — a que mora em `functions/`, ao lado do
70
+ * `microteia.yaml`.
71
+ *
72
+ * É o mesmo defeito de reivindicar uma function da plataforma, com o mesmo par de consequências
73
+ * invisíveis, e por isso é conferido no mesmo lugar. A comum não tem dono por construção: ela é o lugar
74
+ * de quem serve várias extensões, nenhuma pode declará-la (duas reivindicando o mesmo nome é recusa), e
75
+ * o `deploy` a sobe SEMPRE. Reivindicá-la, então, não muda nada enquanto a extensão está instalada — e
76
+ * no dia em que ela sai, a function vira órfã, `--prune` a apaga, e o `deploy` seguinte a repõe. As
77
+ * outras extensões que dependiam dela perdem o backend numa janela que nada explica, e a fonte continua
78
+ * intacta em `functions/`, sem nada na árvore que sequer sugira o que aconteceu.
79
+ *
80
+ * A assimetria com o `pack` é a mesma de `baselineClaimProblems`: o pacote do kernel não sabe o que o
81
+ * escritório vai escrever em `functions/`, então a pergunta só existe do lado de quem instala.
82
+ */
83
+ export declare const sharedClaimProblems: (claims: BackendClaim[], sharedFunctions: string[]) => string[];
57
84
  /**
58
85
  * Índice reivindicado que não existe na raiz. É conferência de EMPACOTAMENTO, não de provisionamento:
59
86
  * a raiz é o que roda em `microteia-dev` e `curatino`, então uma declaração que não esteja lá deixaria
@@ -61,6 +61,44 @@ export const functionClaimProblems = (claims, packagedFunctions) => {
61
61
  }
62
62
  return problems;
63
63
  };
64
+ /**
65
+ * Reivindicar uma function que a PLATAFORMA sobe sempre.
66
+ *
67
+ * É conferência de PROVISIONAMENTO, não de empacotamento, e a assimetria é da própria definição: no
68
+ * `pack` a baseline é DERIVADA (`todas − reivindicadas`), então reivindicar é o que tira o nome dela e
69
+ * a pergunta não existe. Do lado de quem instala a baseline já está decidida e viaja no manifesto, e
70
+ * aí a reivindicação de um nome dela é uma contradição com duas consequências, nenhuma visível: o
71
+ * `deploy` sobe a function de qualquer jeito, e a desinstalação da extensão faz `--prune` apagar um
72
+ * gatilho do produto num escritório que não fez nada.
73
+ */
74
+ export const baselineClaimProblems = (claims, baselineFunctions) => {
75
+ const baseline = new Set(baselineFunctions);
76
+ return claims.flatMap((claim) => (claim.backend.functions ?? [])
77
+ .filter((name) => baseline.has(name))
78
+ .map((name) => `${claim.pluginId} claims function "${name}", which the platform itself deploys`));
79
+ };
80
+ /**
81
+ * Reivindicar uma function COMUM do escritório — a que mora em `functions/`, ao lado do
82
+ * `microteia.yaml`.
83
+ *
84
+ * É o mesmo defeito de reivindicar uma function da plataforma, com o mesmo par de consequências
85
+ * invisíveis, e por isso é conferido no mesmo lugar. A comum não tem dono por construção: ela é o lugar
86
+ * de quem serve várias extensões, nenhuma pode declará-la (duas reivindicando o mesmo nome é recusa), e
87
+ * o `deploy` a sobe SEMPRE. Reivindicá-la, então, não muda nada enquanto a extensão está instalada — e
88
+ * no dia em que ela sai, a function vira órfã, `--prune` a apaga, e o `deploy` seguinte a repõe. As
89
+ * outras extensões que dependiam dela perdem o backend numa janela que nada explica, e a fonte continua
90
+ * intacta em `functions/`, sem nada na árvore que sequer sugira o que aconteceu.
91
+ *
92
+ * A assimetria com o `pack` é a mesma de `baselineClaimProblems`: o pacote do kernel não sabe o que o
93
+ * escritório vai escrever em `functions/`, então a pergunta só existe do lado de quem instala.
94
+ */
95
+ export const sharedClaimProblems = (claims, sharedFunctions) => {
96
+ const shared = new Set(sharedFunctions);
97
+ return claims.flatMap((claim) => (claim.backend.functions ?? [])
98
+ .filter((name) => shared.has(name))
99
+ .map((name) => `${claim.pluginId} claims function "${name}", which is a shared function of this `
100
+ + 'installation'));
101
+ };
64
102
  /**
65
103
  * Índice reivindicado que não existe na raiz. É conferência de EMPACOTAMENTO, não de provisionamento:
66
104
  * a raiz é o que roda em `microteia-dev` e `curatino`, então uma declaração que não esteja lá deixaria
@@ -87,4 +87,4 @@ export declare const consoleRoot: string;
87
87
  * de código nunca chegou a quem instala. Este bloco fica sendo o que ele já era antes de crescer: a
88
88
  * política de semver da ferramenta, mais o registro do que motivou as versões até a `2.5.0`.
89
89
  */
90
- export declare const CLI_VERSION = "2.10.0";
90
+ export declare const CLI_VERSION = "2.18.0";
@@ -94,4 +94,4 @@ export const consoleRoot = resolve(here, '..', '..');
94
94
  * de código nunca chegou a quem instala. Este bloco fica sendo o que ele já era antes de crescer: a
95
95
  * política de semver da ferramenta, mais o registro do que motivou as versões até a `2.5.0`.
96
96
  */
97
- export const CLI_VERSION = '2.10.0';
97
+ export const CLI_VERSION = '2.18.0';
@@ -16,10 +16,21 @@ export interface HostingConfig {
16
16
  headers: unknown[];
17
17
  }
18
18
  export declare const packageFirebaseJson: (hosting: HostingConfig) => string;
19
+ /**
20
+ * O nome do pacote de functions da instalação, e o subcaminho da superfície curada que a Cloud Function
21
+ * do ESCRITÓRIO importa dele: `import { forInstalledExtension } from 'microteia-functions/office'`.
22
+ *
23
+ * O nome é parte do contrato de autoria a partir do momento em que alguém o escreve num import, e por
24
+ * isso ele deixou de ser uma string solta aqui dentro.
25
+ */
26
+ export declare const FUNCTIONS_PACKAGE_NAME = "microteia-functions";
27
+ export declare const OFFICE_SUBPATH = "office";
28
+ export declare const OFFICE_MODULE = "microteia-functions/office";
19
29
  /**
20
30
  * As dependências são copiadas INTACTAS do `functions/package.json` do repositório, para que o
21
31
  * `package-lock.json` que viaja junto continue válido. O que muda é só o entrypoint (passa a ser o
22
- * `index.js` que soma kernel e custom) e o `build`, que agora compila apenas `custom/`.
32
+ * `index.js` que soma kernel e custom), o `build`, que agora compila apenas `custom/`, e o `exports`,
33
+ * que abre a superfície curada para o `custom/` sem custar uma dependência.
23
34
  */
24
35
  /**
25
36
  * A `version` não é enfeite: sem ela o npm não consegue formar `nome@versão` e todo aviso dele sobre