@microteia/console 2.8.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.
- package/CONSOLE-VERSIONS.md +97 -0
- package/README.md +1 -1
- package/lib/console/appliedFunctions.d.ts +79 -0
- package/lib/console/appliedFunctions.js +88 -0
- package/lib/console/backendResidue.d.ts +55 -0
- package/lib/console/backendResidue.js +53 -0
- package/lib/console/customFunctions.d.ts +51 -1
- package/lib/console/customFunctions.js +94 -1
- package/lib/console/deploy.js +32 -7
- package/lib/console/environment.d.ts +22 -6
- package/lib/console/environment.js +11 -0
- package/lib/console/exportCatalog.d.ts +124 -0
- package/lib/console/exportCatalog.js +264 -0
- package/lib/console/firestoreRest.d.ts +27 -0
- package/lib/console/firestoreRest.js +23 -0
- package/lib/console/functionGate.d.ts +198 -0
- package/lib/console/functionGate.js +400 -0
- package/lib/console/init.js +1 -0
- package/lib/console/installedExtensions.d.ts +37 -0
- package/lib/console/installedExtensions.js +37 -0
- package/lib/console/loginExtensions.d.ts +29 -0
- package/lib/console/loginExtensions.js +101 -0
- package/lib/console/provision.d.ts +26 -13
- package/lib/console/provision.js +312 -73
- package/lib/index.js +8 -0
- package/lib/kernel/backendShape.d.ts +27 -0
- package/lib/kernel/backendShape.js +38 -0
- package/lib/kernel/manifest.d.ts +16 -0
- package/lib/kernel/manifest.js +16 -0
- package/lib/shared/paths.d.ts +1 -1
- package/lib/shared/paths.js +1 -1
- package/lib/vendor/exampleSources.js +7 -3
- package/lib/vendor/installationDocs.js +18 -0
- package/lib/vendor/pack.js +3 -1
- package/lib/vendor/packageTemplates.d.ts +12 -1
- package/lib/vendor/packageTemplates.js +42 -2
- package/package.json +1 -1
package/lib/console/provision.js
CHANGED
|
@@ -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';
|
|
@@ -13,8 +13,12 @@ import { deployFunctionsOneByOne, reportFailures, reportNotices } from './deploy
|
|
|
13
13
|
import { clearRegionOrphans, listDeployedFunctions } from './regionOrphans.js';
|
|
14
14
|
import { outdatedByStamp } from './functionsUpToDate.js';
|
|
15
15
|
import { checkCallables, invokerGrantCommand } from './callableAccess.js';
|
|
16
|
-
import { asString, asTimestamp,
|
|
16
|
+
import { asString, asTimestamp, patchMapField } from './firestoreRest.js';
|
|
17
|
+
import { writeBackendResidue } from './backendResidue.js';
|
|
18
|
+
import { listTenants, readInstalledExtensions } from './installedExtensions.js';
|
|
17
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';
|
|
18
22
|
/**
|
|
19
23
|
* O que cada escritório do projeto instalou e exige do backend.
|
|
20
24
|
*
|
|
@@ -22,34 +26,38 @@ import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js
|
|
|
22
26
|
* contra `manifest.functions` antes de qualquer deploy. Nome de function fora do pacote é recusado com
|
|
23
27
|
* explicação; não há injeção possível, porque o código é sempre o do pacote.
|
|
24
28
|
*/
|
|
25
|
-
export const
|
|
26
|
-
const tenants = await
|
|
29
|
+
export const surveyInstallations = async (projectId) => {
|
|
30
|
+
const tenants = await listTenants(projectId);
|
|
27
31
|
const claims = [];
|
|
32
|
+
const installedIds = new Set();
|
|
33
|
+
let extensions = 0;
|
|
28
34
|
for (const tenant of tenants) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const data = decodeFields(document.fields);
|
|
33
|
-
if (data.active === false)
|
|
35
|
+
for (const extension of await readInstalledExtensions(projectId, tenant.id)) {
|
|
36
|
+
extensions += 1;
|
|
37
|
+
if (!extension.active)
|
|
34
38
|
continue;
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
const backend = manifest?.backend;
|
|
39
|
+
installedIds.add(extension.id);
|
|
40
|
+
const backend = extension.manifest?.backend;
|
|
38
41
|
if (!hasBackendRequirement(backend))
|
|
39
42
|
continue;
|
|
40
|
-
const state = data.backendState;
|
|
43
|
+
const state = extension.data.backendState;
|
|
41
44
|
claims.push({
|
|
42
|
-
tenantId,
|
|
43
|
-
documentPath:
|
|
44
|
-
pluginId:
|
|
45
|
-
name:
|
|
46
|
-
version:
|
|
45
|
+
tenantId: extension.tenantId,
|
|
46
|
+
documentPath: extension.documentPath,
|
|
47
|
+
pluginId: extension.id,
|
|
48
|
+
name: extension.name,
|
|
49
|
+
version: extension.version,
|
|
47
50
|
backend: backend,
|
|
48
51
|
applied: state?.fingerprint === backendFingerprint(backend),
|
|
49
52
|
});
|
|
50
53
|
}
|
|
51
54
|
}
|
|
52
|
-
return
|
|
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
|
+
};
|
|
53
61
|
};
|
|
54
62
|
const uniqueByPlugin = (claims) => {
|
|
55
63
|
const seen = new Set();
|
|
@@ -60,7 +68,6 @@ const uniqueByPlugin = (claims) => {
|
|
|
60
68
|
return true;
|
|
61
69
|
});
|
|
62
70
|
};
|
|
63
|
-
export const parseAppliedFunctions = (value) => (value ?? '').split(',').map((name) => name.trim()).filter((name) => name !== '');
|
|
64
71
|
/**
|
|
65
72
|
* O motor: alinha o projeto Firebase com o que as extensões instaladas exigem, e carimba nelas o
|
|
66
73
|
* estado. Um motor só, chamado pelo `deploy` no fim e pelo `provision` sozinho — duas implementações
|
|
@@ -81,12 +88,26 @@ export const provisionBackend = async (options) => {
|
|
|
81
88
|
*/
|
|
82
89
|
const office = await readOfficeFunctions(root);
|
|
83
90
|
const deployable = [...manifest.functions, ...office.all];
|
|
84
|
-
const
|
|
85
|
-
const
|
|
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
|
+
];
|
|
86
107
|
const rejected = new Set();
|
|
87
|
-
if (
|
|
108
|
+
if (missing.length > 0) {
|
|
88
109
|
warn('some extensions ask for backend artifacts that are nowhere to be found:');
|
|
89
|
-
bullets(
|
|
110
|
+
bullets(missing);
|
|
90
111
|
// A mensagem sozinha é um beco sem saída: ela diz que o nome não existe, e não onde ele deveria
|
|
91
112
|
// existir. As duas respostas possíveis são estas, e a segunda é a que mais acontece — a function é
|
|
92
113
|
// da extensão, o autor a declarou, e ninguém a exportou do `index.ts` do diretório dela.
|
|
@@ -97,18 +118,92 @@ export const provisionBackend = async (options) => {
|
|
|
97
118
|
]);
|
|
98
119
|
info(` this installation currently exports ${office.all.length} function(s) of its own`
|
|
99
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) {
|
|
100
184
|
for (const claim of claims) {
|
|
101
185
|
if (problems.some((problem) => problem.includes(claim.pluginId)))
|
|
102
186
|
rejected.add(claim.pluginId);
|
|
103
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);
|
|
104
192
|
}
|
|
105
193
|
const valid = uniqueByPlugin(claims.filter((claim) => !rejected.has(claim.pluginId)));
|
|
106
194
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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.
|
|
111
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
|
+
}
|
|
112
207
|
if (valid.length === 0) {
|
|
113
208
|
info(' no installed extension asks for backend artifacts');
|
|
114
209
|
}
|
|
@@ -117,14 +212,60 @@ export const provisionBackend = async (options) => {
|
|
|
117
212
|
bullets(valid.map((claim) => `${claim.name} ${claim.version} (${claim.pluginId})`));
|
|
118
213
|
}
|
|
119
214
|
const settled = await settleIndexes(options, valid);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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);
|
|
124
250
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
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();
|
|
128
269
|
// A raiz, e não o `install/`: a fonte é o arquivo que o escritório edita ao lado do `microteia.yaml`.
|
|
129
270
|
const filledParameters = await readBackendEnv(dirname(options.environmentFile), projectId);
|
|
130
271
|
const recordedDigests = parseParameterState(options.environment.backend.parameters);
|
|
@@ -141,13 +282,46 @@ export const provisionBackend = async (options) => {
|
|
|
141
282
|
// instalação fica sem nenhum comando que o aplique, e a feature falha no runtime.
|
|
142
283
|
const changed = changedParameters(recordedDigests, currentDigests, trackedParameters);
|
|
143
284
|
const staleFunctions = new Set(functionsDeclaringParameters(valid, changed)
|
|
144
|
-
.filter((name) => wantedFunctions.includes(name) &&
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
+
};
|
|
148
319
|
// O que a lista de nomes nunca respondeu: a function aplicada que sumiu do projeto, e a que segue
|
|
149
320
|
// lá carregando o carimbo de um código anterior. As duas voltam para a fila.
|
|
150
|
-
const
|
|
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);
|
|
151
325
|
for (const name of [...outdated.absent, ...outdated.stale])
|
|
152
326
|
staleFunctions.add(name);
|
|
153
327
|
if (outdated.absent.length > 0) {
|
|
@@ -184,9 +358,13 @@ export const provisionBackend = async (options) => {
|
|
|
184
358
|
info(' Cloud Function at ITS deploy, so a function already running keeps the previous one.');
|
|
185
359
|
blank();
|
|
186
360
|
}
|
|
187
|
-
const toDeploy = wantedFunctions.filter((name) => options.all === true || !
|
|
361
|
+
const toDeploy = wantedFunctions.filter((name) => options.all === true || !appliedNames.has(name) || staleFunctions.has(name));
|
|
188
362
|
if (toDeploy.length === 0) {
|
|
189
|
-
|
|
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
|
+
}
|
|
190
368
|
}
|
|
191
369
|
else {
|
|
192
370
|
// A cópia que o `firebase-functions` lê é atualizada AGORA, e não só no `deploy`: este comando é o
|
|
@@ -203,7 +381,7 @@ export const provisionBackend = async (options) => {
|
|
|
203
381
|
}
|
|
204
382
|
// A extensão que mudou de região deixa a cópia antiga para trás igual ao kernel, e ali o efeito
|
|
205
383
|
// é o mesmo: deploy recusado em toda tentativa, e gatilho disparando em duas regiões.
|
|
206
|
-
await clearRegionOrphans(projectId, toDeploy, region, options.prune,
|
|
384
|
+
await clearRegionOrphans(projectId, toDeploy, region, options.prune, await projectFunctions());
|
|
207
385
|
info(` deploying ${toDeploy.length} function(s), one at a time…`);
|
|
208
386
|
const report = await deployFunctionsOneByOne(toDeploy, {
|
|
209
387
|
projectId, cwd: install, delaySeconds, force: false,
|
|
@@ -214,7 +392,7 @@ export const provisionBackend = async (options) => {
|
|
|
214
392
|
reportNotices(report.notices);
|
|
215
393
|
await checkNewCallables([...(manifest.callables ?? []), ...office.callables], report.deployed, projectId, region);
|
|
216
394
|
}
|
|
217
|
-
const deployedNow = new Set([...
|
|
395
|
+
const deployedNow = new Set([...appliedNames, ...outcome.deployed]);
|
|
218
396
|
for (const claim of valid) {
|
|
219
397
|
const satisfied = (claim.backend.functions ?? []).every((name) => deployedNow.has(name));
|
|
220
398
|
if (satisfied) {
|
|
@@ -224,14 +402,16 @@ export const provisionBackend = async (options) => {
|
|
|
224
402
|
outcome.pending.push(claim);
|
|
225
403
|
}
|
|
226
404
|
}
|
|
227
|
-
|
|
228
|
-
outcome.orphans = orphans;
|
|
229
|
-
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);
|
|
230
408
|
await recordBackendState(options, appliedAfterRun({
|
|
231
|
-
|
|
409
|
+
applied,
|
|
232
410
|
wanted: wantedFunctions,
|
|
233
411
|
deployed: outcome.deployed,
|
|
234
412
|
keptOrphans: remaining,
|
|
413
|
+
preserved: classification.preserved.map((item) => item.entry),
|
|
414
|
+
owners,
|
|
235
415
|
}), appliedParameterState({
|
|
236
416
|
recorded: recordedDigests,
|
|
237
417
|
current: currentDigests,
|
|
@@ -240,8 +420,37 @@ export const provisionBackend = async (options) => {
|
|
|
240
420
|
claims: valid,
|
|
241
421
|
}), settled.declared);
|
|
242
422
|
reportOrphanIndexes(projectId, settled.orphans, options.prune);
|
|
423
|
+
await publishResidue(projectId, remaining, settled.orphans, survey);
|
|
243
424
|
return outcome;
|
|
244
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
|
+
};
|
|
245
454
|
/**
|
|
246
455
|
* O que o projeto tem de declarar de índice composto, e o que sobrou de uma extensão que saiu.
|
|
247
456
|
*
|
|
@@ -324,34 +533,74 @@ const stampApplied = async (projectId, all, claim) => {
|
|
|
324
533
|
success(`${claim.name} is ready in ${projectId}`);
|
|
325
534
|
};
|
|
326
535
|
/**
|
|
327
|
-
*
|
|
328
|
-
*
|
|
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.
|
|
329
571
|
*/
|
|
330
|
-
const settleOrphans = async (options, orphans) => {
|
|
572
|
+
const settleOrphans = async (options, orphans, survey) => {
|
|
331
573
|
if (orphans.length === 0)
|
|
332
574
|
return [];
|
|
333
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).`);
|
|
334
580
|
if (!options.prune) {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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:');
|
|
338
587
|
bullets(['microteia provision --prune']);
|
|
339
588
|
blank();
|
|
340
589
|
return orphans;
|
|
341
590
|
}
|
|
342
591
|
info(` deleting ${orphans.length} orphan function(s)…`);
|
|
343
|
-
const deleted =
|
|
344
|
-
for (const
|
|
592
|
+
const deleted = new Set();
|
|
593
|
+
for (const orphan of orphans) {
|
|
345
594
|
try {
|
|
346
|
-
await run(FIREBASE_BIN, ['functions:delete', name, '--project', options.projectId, '--region', options.region, '--force'], { cwd: options.install });
|
|
347
|
-
deleted.
|
|
595
|
+
await run(FIREBASE_BIN, ['functions:delete', orphan.name, '--project', options.projectId, '--region', options.region, '--force'], { cwd: options.install });
|
|
596
|
+
deleted.add(orphan.name);
|
|
348
597
|
}
|
|
349
598
|
catch {
|
|
350
|
-
warn(`could not delete ${name}; it stays recorded as deployed`);
|
|
599
|
+
warn(`could not delete ${orphan.name}; it stays recorded as deployed`);
|
|
351
600
|
}
|
|
352
601
|
}
|
|
353
|
-
success(`${deleted.
|
|
354
|
-
return orphans.filter((
|
|
602
|
+
success(`${deleted.size} orphan function(s) removed`);
|
|
603
|
+
return orphans.filter((orphan) => !deleted.has(orphan.name));
|
|
355
604
|
};
|
|
356
605
|
/**
|
|
357
606
|
* Índice órfão é RELATADO, nunca apagado às cegas: o `firebase deploy` não remove índice por conta, e a
|
|
@@ -384,16 +633,6 @@ const reportOrphanIndexes = (projectId, orphans, prune) => {
|
|
|
384
633
|
]);
|
|
385
634
|
blank();
|
|
386
635
|
};
|
|
387
|
-
/**
|
|
388
|
-
* A lista aplicada CONVERGE para o que ainda é exigido: o que já estava e continua sendo pedido, mais
|
|
389
|
-
* o que subiu agora, mais o órfão que não foi removido. Reescrevê-la só com o que subiu NESTA execução
|
|
390
|
-
* a esvaziava na segunda passada — e a terceira redeployava tudo, achando que nada havia sido feito.
|
|
391
|
-
*/
|
|
392
|
-
export const appliedAfterRun = (input) => [...new Set([
|
|
393
|
-
...input.alreadyApplied.filter((name) => input.wanted.includes(name)),
|
|
394
|
-
...input.deployed,
|
|
395
|
-
...input.keptOrphans,
|
|
396
|
-
])].sort();
|
|
397
636
|
/**
|
|
398
637
|
* A impressão digital do parâmetro só avança quando TODAS as functions que o carregam subiram. Gravar
|
|
399
638
|
* antes disso é o que transformaria uma falha de deploy em silêncio permanente: o valor novo ficaria
|
|
@@ -418,7 +657,7 @@ export const appliedParameterState = (input) => {
|
|
|
418
657
|
}
|
|
419
658
|
return next;
|
|
420
659
|
};
|
|
421
|
-
const recordBackendState = async (options,
|
|
660
|
+
const recordBackendState = async (options, applied, parameters, indexes) => {
|
|
422
661
|
const state = serializeIndexState(indexes);
|
|
423
662
|
/**
|
|
424
663
|
* Índice que a codificação não representa sem ambiguidade fica de fora COM AVISO, em vez de voltar do
|
|
@@ -435,7 +674,7 @@ const recordBackendState = async (options, names, parameters, indexes) => {
|
|
|
435
674
|
await writeEnvironment(options.environmentFile, {
|
|
436
675
|
...options.environment,
|
|
437
676
|
backend: {
|
|
438
|
-
functions:
|
|
677
|
+
functions: serializeAppliedFunctions(applied),
|
|
439
678
|
parameters: serializeParameterState(parameters),
|
|
440
679
|
indexes: state.value,
|
|
441
680
|
},
|
package/lib/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { connect } from './console/connect.js';
|
|
|
10
10
|
import { unpack } from './console/unpack.js';
|
|
11
11
|
import { deploy } from './console/deploy.js';
|
|
12
12
|
import { provision } from './console/provision.js';
|
|
13
|
+
import { exportCatalog } from './console/exportCatalog.js';
|
|
13
14
|
import { pluginInit } from './console/pluginWorkspace.js';
|
|
14
15
|
import { versionReport } from './console/versions.js';
|
|
15
16
|
import { newerThan } from './console/channel.js';
|
|
@@ -49,6 +50,13 @@ const COMMANDS = [
|
|
|
49
50
|
audience: 'office',
|
|
50
51
|
execute: provision,
|
|
51
52
|
},
|
|
53
|
+
{
|
|
54
|
+
name: 'export-catalog',
|
|
55
|
+
usage: 'microteia export-catalog --tenant <tenantId> [--output <path>]',
|
|
56
|
+
summary: 'Write what an office has — screens, lists, extensions — for the authoring tools to read.',
|
|
57
|
+
audience: 'office',
|
|
58
|
+
execute: exportCatalog,
|
|
59
|
+
},
|
|
52
60
|
{
|
|
53
61
|
name: 'plugin-init',
|
|
54
62
|
usage: 'microteia plugin-init [--dir <path>] [--force]',
|
|
@@ -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
|