@microteia/console 2.7.2 → 2.10.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.
@@ -0,0 +1,55 @@
1
+ import type { FunctionDeployOptions, FunctionDeployReport } from './deployFunctions.js';
2
+ import type { DeployedFunction } from './regionOrphans.js';
3
+ /** As functions que o projeto roda com exatamente o mesmo carimbo. */
4
+ export interface HashGroup {
5
+ hash: string;
6
+ platform: string;
7
+ names: string[];
8
+ }
9
+ export interface DeployPlan {
10
+ /**
11
+ * Sem carimbo com que comparar: ausente do projeto, em outra região, no meio de um deploy, ou
12
+ * deployada por um CLI que não gravava o label. Sobem sempre — não saber nunca vira pular.
13
+ */
14
+ unverifiable: string[];
15
+ groups: HashGroup[];
16
+ }
17
+ export declare const planFunctionDeploy: (wanted: string[], deployed: DeployedFunction[] | null, region: string) => DeployPlan;
18
+ export interface UpToDateReport extends FunctionDeployReport {
19
+ /** Não subiram porque o projeto já roda exatamente este código. */
20
+ skipped: string[];
21
+ /**
22
+ * O carimbo antigo de cada grupo sondado, e o que a sonda dele voltou carregando. É o que deixa o
23
+ * provisionamento decidir sobre as functions de extensão sem sondar de novo: elas estão no MESMO
24
+ * codebase, então o carimbo de agora é o mesmo.
25
+ */
26
+ refreshed: Map<string, string>;
27
+ }
28
+ export interface UpToDateContext {
29
+ /** A listagem que o comando já fez, para não perguntar duas vezes a mesma coisa ao projeto. */
30
+ deployed: DeployedFunction[] | null;
31
+ region: string;
32
+ /** `--all`: sobe todas, sem perguntar ao projeto o que ele já roda. */
33
+ all: boolean;
34
+ }
35
+ export declare const deployFunctionsThatChanged: (wanted: string[], options: FunctionDeployOptions, context: UpToDateContext) => Promise<UpToDateReport>;
36
+ /**
37
+ * A function de extensão que o projeto roda e que este pacote não constrói mais assim.
38
+ *
39
+ * Ela existe porque o provisionamento sempre decidiu por NOME: uma function já aplicada só voltava
40
+ * para a fila quando um parâmetro dela mudava de valor. Editar o código em
41
+ * `extensions/<plugin>/functions/` não movia nada — a fonte mudava, o pacote era remontado, e a
42
+ * function seguia rodando a versão anterior sem que comando nenhum a alcançasse.
43
+ *
44
+ * O carimbo responde isso de graça, e sem sondar nada: a function de extensão está no MESMO codebase
45
+ * da baseline, então o carimbo que a sonda do `deploy` acabou de observar é o dela também.
46
+ */
47
+ export interface OutdatedFunctions {
48
+ /** Não estão rodando em lugar nenhum do projeto, apesar de a lista de nomes dizer que subiram. */
49
+ absent: string[];
50
+ /** Estão lá, e carregam um carimbo anterior ao que este pacote constrói. */
51
+ stale: string[];
52
+ }
53
+ export declare const outdatedByStamp: (applied: string[], deployed: DeployedFunction[] | null, region: string,
54
+ /** Carimbo antigo -> carimbo de agora, observado pelas sondas. Ausente: não há o que concluir. */
55
+ refreshed: Map<string, string> | undefined) => OutdatedFunctions;
@@ -0,0 +1,192 @@
1
+ import { blank, info, success, warn } from '../shared/ui.js';
2
+ import { deployFunctionsOneByOne } from './deployFunctions.js';
3
+ import { listDeployedFunctions } from './regionOrphans.js';
4
+ /**
5
+ * O deploy que não sobe de novo o que o projeto já roda.
6
+ *
7
+ * O `firebase` sabe responder isso, e sempre soube: ele grava em cada function o label
8
+ * `firebase-functions-hash` — sha1 do diretório de fontes INTEIRO, mais as variáveis de ambiente
9
+ * resolvidas, mais as versões de segredo — e num deploy em lote pula toda function cujo carimbo
10
+ * gravado é igual ao que o código atual produz. O que desliga esse pulo é justamente o
11
+ * `--only functions:<nome>` com que o Console deploya: `planner.js` só pula endpoint que
12
+ * `!targetedByOnly`, e nomear a function no `--only` a marca. Deployar uma a uma não é opcional (em
13
+ * lote estoura a quota de CPU do projeto), então o pulo teve de ser reconstruído aqui.
14
+ *
15
+ * **Não é um mecanismo de detecção novo, e essa é a decisão inteira.** Não há digest nosso, não há
16
+ * lista de insumos que alguém possa esquecer de atualizar, não há estado guardado que possa divergir
17
+ * do projeto. O carimbo é do `firebase`: calculado por ele, gravado por ele na function, lido do
18
+ * projeto a cada execução. Comparar por ele é tomar a MESMA decisão que o `firebase deploy` tomaria.
19
+ *
20
+ * O que faltava é o outro lado da comparação: o carimbo que o código de AGORA produziria. Calculá-lo
21
+ * aqui exigiria reimplementar o algoritmo dele — e uma reimplementação que envelhece em silêncio é
22
+ * exatamente o que não se quer num comando que decide NÃO publicar. Em vez disso ele é OBSERVADO: as
23
+ * functions são agrupadas pelo carimbo que carregam, UMA de cada grupo é deployada, e o carimbo que
24
+ * ela volta carregando é o carimbo do código de agora. Voltou igual, o grupo inteiro é byte a byte o
25
+ * que o projeto já roda; voltou diferente, o grupo inteiro sobe.
26
+ *
27
+ * A sonda ser MEMBRO do grupo é o que faz a conclusão valer para os outros: duas functions com o
28
+ * mesmo carimbo antigo têm, além da mesma fonte e do mesmo ambiente, o mesmo conjunto de segredos —
29
+ * é a terceira parcela do hash. Uma sonda de fora do grupo não provaria isso.
30
+ *
31
+ * Ler o carimbo da sonda logo depois de ela subir depende de o `functions:list` já enxergar o que o
32
+ * deploy acabou de gravar. Não é uma aposta nossa: é a MESMA leitura de que o `firebase deploy`
33
+ * depende para montar o `haveBackend` com que ele decide o que pular, e o comando só retorna depois
34
+ * de a operação do Google ter terminado.
35
+ *
36
+ * O custo do pior caso é o comportamento de hoje; o do caso bom é uma function em vez de 28.
37
+ *
38
+ * O que ele NÃO enxerga — porque o `firebase deploy` em lote também não — é configuração trocada por
39
+ * fora, pelo console do Google: memória, timeout, concorrência, política de invocação. Para isso
40
+ * existe `--all`. O alcance das callables continua conferido em TODAS a cada deploy, inclusive nas
41
+ * que não subiram, e é ele que acusa uma concessão de IAM removida por fora.
42
+ */
43
+ const ACTIVE = 'ACTIVE';
44
+ const SEPARATOR = ' ';
45
+ export const planFunctionDeploy = (wanted, deployed, region) => {
46
+ if (deployed === null)
47
+ return { unverifiable: [...wanted], groups: [] };
48
+ const byName = new Map();
49
+ for (const entry of deployed) {
50
+ if (entry.region === region)
51
+ byName.set(entry.name, entry);
52
+ }
53
+ const unverifiable = [];
54
+ const grouped = new Map();
55
+ for (const name of wanted) {
56
+ const entry = byName.get(name);
57
+ if (entry === undefined || entry.state !== ACTIVE || entry.hash === null) {
58
+ unverifiable.push(name);
59
+ continue;
60
+ }
61
+ // A plataforma entra na chave porque `gcfv1` e `gcfv2` empacotam fontes diferentes e carimbam
62
+ // hashes diferentes: o carimbo de uma nunca fala pela outra.
63
+ const key = `${entry.platform}${SEPARATOR}${entry.hash}`;
64
+ const names = grouped.get(key);
65
+ if (names === undefined)
66
+ grouped.set(key, [name]);
67
+ else
68
+ names.push(name);
69
+ }
70
+ const groups = [...grouped.entries()].map(([key, names]) => {
71
+ const [platform, hash] = key.split(SEPARATOR);
72
+ return { hash, platform, names };
73
+ });
74
+ return { unverifiable, groups };
75
+ };
76
+ const merge = (left, right) => ({
77
+ deployed: [...left.deployed, ...right.deployed],
78
+ failed: [...left.failed, ...right.failed],
79
+ notices: [...new Set([...left.notices, ...right.notices])],
80
+ });
81
+ const plural = (count, singular) => (count === 1 ? singular : `${singular}s`);
82
+ export const deployFunctionsThatChanged = async (wanted, options, context) => {
83
+ const plain = async (names) => ({
84
+ ...(await deployFunctionsOneByOne(names, options)),
85
+ skipped: [],
86
+ refreshed: new Map(),
87
+ });
88
+ if (wanted.length === 0)
89
+ return plain(wanted);
90
+ if (context.all) {
91
+ info(` --all: deploying all ${wanted.length}, without asking the project what it already runs.`);
92
+ return plain(wanted);
93
+ }
94
+ const plan = planFunctionDeploy(wanted, context.deployed, context.region);
95
+ if (plan.groups.length === 0) {
96
+ // Nada com que comparar. Numa instalação nova é o caso normal, e é o certo: não há nada lá.
97
+ return plain(wanted);
98
+ }
99
+ const comparable = plan.groups.reduce((count, group) => count + group.names.length, 0);
100
+ const probes = plan.groups.map((group) => group.names[0]);
101
+ const candidates = comparable - probes.length;
102
+ info(` ${comparable} of the ${wanted.length} already run in this project carrying a Firebase stamp`);
103
+ info(` of the source, settings and secrets they were built with. Deploying ${probes.length} reference`);
104
+ info(` ${plural(probes.length, 'function')} first (${probes.join(', ')}): if the stamp comes back`);
105
+ info(` unchanged, the other ${candidates} are what the project already runs and are left alone.`);
106
+ info(' It is the same comparison "firebase deploy" makes when it deploys in bulk.');
107
+ blank();
108
+ // As sondas primeiro: é o que se aprende com elas que decide o resto.
109
+ const first = await deployFunctionsOneByOne([...probes, ...plan.unverifiable], options);
110
+ const fresh = await listDeployedFunctions(options.projectId);
111
+ const freshByName = new Map();
112
+ for (const entry of fresh ?? []) {
113
+ if (entry.region === context.region)
114
+ freshByName.set(entry.name, entry);
115
+ }
116
+ const refreshed = new Map();
117
+ const skipped = [];
118
+ const rest = [];
119
+ let unconfirmed = 0;
120
+ for (const group of plan.groups) {
121
+ const [probe, ...others] = group.names;
122
+ if (others.length === 0)
123
+ continue;
124
+ const entry = freshByName.get(probe);
125
+ const stamp = first.deployed.includes(probe) && entry !== undefined && entry.state === ACTIVE
126
+ ? entry.hash
127
+ : null;
128
+ if (stamp === null) {
129
+ // Não deu para ler o carimbo novo da sonda: a resposta é subir, que é o que se fazia antes
130
+ // deste mecanismo existir. Não saber nunca vira pular.
131
+ unconfirmed += others.length;
132
+ rest.push(...others);
133
+ continue;
134
+ }
135
+ refreshed.set(group.hash, stamp);
136
+ if (stamp === group.hash)
137
+ skipped.push(...others);
138
+ else
139
+ rest.push(...others);
140
+ }
141
+ blank();
142
+ if (skipped.length > 0) {
143
+ success(`${skipped.length} ${plural(skipped.length, 'function')} already up to date — same source,`);
144
+ info(' settings and secrets as what this project runs. Not deployed.');
145
+ }
146
+ if (unconfirmed > 0) {
147
+ warn(`could not read the new stamp of ${unconfirmed} ${plural(unconfirmed, 'function')}: deploying them.`);
148
+ }
149
+ if (rest.length > 0) {
150
+ info(` ${rest.length} ${plural(rest.length, 'function')} changed and go up now.`);
151
+ }
152
+ blank();
153
+ const second = await deployFunctionsOneByOne(rest, options);
154
+ return { ...merge(first, second), skipped, refreshed };
155
+ };
156
+ export const outdatedByStamp = (applied, deployed, region,
157
+ /** Carimbo antigo -> carimbo de agora, observado pelas sondas. Ausente: não há o que concluir. */
158
+ refreshed) => {
159
+ if (deployed === null)
160
+ return { absent: [], stale: [] };
161
+ const byName = new Map();
162
+ const running = new Set();
163
+ for (const entry of deployed) {
164
+ // A ausência é perguntada ao projeto INTEIRO, e não à região do pacote: uma function que o
165
+ // escritório escreveu de propósito noutra região está rodando, e devolvê-la à fila por não estar
166
+ // aqui a redeployaria em toda execução, para sempre. É a mesma distinção que `regionOrphans` faz
167
+ // ao casar por nome-que-vai-subir em vez de por região diferente da esperada.
168
+ if (entry.state === ACTIVE)
169
+ running.add(entry.name);
170
+ if (entry.region === region)
171
+ byName.set(entry.name, entry);
172
+ }
173
+ const absent = [];
174
+ const stale = [];
175
+ for (const name of applied) {
176
+ if (!running.has(name)) {
177
+ absent.push(name);
178
+ continue;
179
+ }
180
+ const entry = byName.get(name);
181
+ if (entry === undefined || entry.state !== ACTIVE)
182
+ continue;
183
+ // Sem carimbo, ou sem o carimbo de agora com que compará-lo, não há conclusão a tirar — e a
184
+ // ausência de conclusão mantém o comportamento anterior a este mecanismo, que é não mexer.
185
+ if (entry.hash === null || refreshed === undefined)
186
+ continue;
187
+ const now = refreshed.get(entry.hash);
188
+ if (now !== undefined && now !== entry.hash)
189
+ stale.push(name);
190
+ }
191
+ return { absent, stale };
192
+ };
@@ -31,6 +31,7 @@ export const init = async (args) => {
31
31
  firebase: { projectId: null, region: DEFAULT_FUNCTIONS_REGION },
32
32
  kernel: { version: null, deployed: null },
33
33
  backend: { functions: null, parameters: null, indexes: null },
34
+ login: { extensions: null },
34
35
  };
35
36
  await writeEnvironment(file, environment);
36
37
  // O diretório de trabalho guarda o pacote desempacotado e a configuração resolvida. Ele é local à
@@ -0,0 +1,37 @@
1
+ /**
2
+ * O CAMINHO ÚNICO até o que um escritório instalou.
3
+ *
4
+ * Estava dentro do `provision`, e é dele que a leitura nasceu: para saber quais Cloud Functions subir,
5
+ * o comando precisa do `manifest.backend` de cada extensão instalada. O `export-catalog` faz a mesma
6
+ * pergunta com outro propósito, e um segundo caminho até o mesmo documento seria uma segunda chance de
7
+ * discordar — a forma do documento, o que conta como instalada, o que fazer com a desativada.
8
+ *
9
+ * Então o caminho é este, e o `provision` também passa por aqui.
10
+ */
11
+ /** Um escritório do projeto. O `name` existe para que escolher um `--tenant` não seja adivinhar um id. */
12
+ export interface TenantSummary {
13
+ id: string;
14
+ name: string;
15
+ }
16
+ export interface InstalledExtensionRecord {
17
+ tenantId: string;
18
+ documentPath: string;
19
+ id: string;
20
+ name: string;
21
+ version: string;
22
+ /** Desligada no escritório: o pacote continua ali, e nada dele está no ar. */
23
+ active: boolean;
24
+ /** O manifesto cru da cópia instalada — é dele que sai `backend`, e nada aqui o interpreta. */
25
+ manifest: Record<string, unknown> | undefined;
26
+ /** O documento inteiro, decodificado, para quem precisa de mais que o manifesto. */
27
+ data: Record<string, unknown>;
28
+ }
29
+ export declare const listTenants: (projectId: string) => Promise<TenantSummary[]>;
30
+ /**
31
+ * As extensões instaladas num escritório.
32
+ *
33
+ * A DESATIVADA vem junto, marcada. Quem decide o que fazer com ela é quem chamou: para o `provision`
34
+ * ela não exige backend nenhum; para o catálogo ela ainda explica de onde veio uma tela que continua
35
+ * gravada no banco.
36
+ */
37
+ export declare const readInstalledExtensions: (projectId: string, tenantId: string) => Promise<InstalledExtensionRecord[]>;
@@ -0,0 +1,37 @@
1
+ import { decodeFields, documentId, listDocuments } from './firestoreRest.js';
2
+ export const listTenants = async (projectId) => {
3
+ const documents = await listDocuments(projectId, '/tenants');
4
+ return documents
5
+ .map((document) => {
6
+ const id = documentId(document);
7
+ const data = decodeFields(document.fields);
8
+ return { id, name: typeof data.name === 'string' && data.name !== '' ? data.name : id };
9
+ })
10
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
11
+ };
12
+ /**
13
+ * As extensões instaladas num escritório.
14
+ *
15
+ * A DESATIVADA vem junto, marcada. Quem decide o que fazer com ela é quem chamou: para o `provision`
16
+ * ela não exige backend nenhum; para o catálogo ela ainda explica de onde veio uma tela que continua
17
+ * gravada no banco.
18
+ */
19
+ export const readInstalledExtensions = async (projectId, tenantId) => {
20
+ const documents = await listDocuments(projectId, `/tenants/${tenantId}/installed_extensions`);
21
+ return documents.map((document) => {
22
+ const data = decodeFields(document.fields);
23
+ const pkg = data.package;
24
+ const manifest = pkg?.manifest;
25
+ const fallbackId = documentId(document);
26
+ return {
27
+ tenantId,
28
+ documentPath: `/tenants/${tenantId}/installed_extensions/${fallbackId}`,
29
+ id: String(manifest?.id ?? fallbackId),
30
+ name: String(manifest?.name ?? manifest?.id ?? fallbackId),
31
+ version: String(manifest?.version ?? '0.0.0'),
32
+ active: data.active !== false,
33
+ manifest,
34
+ data,
35
+ };
36
+ });
37
+ };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Este arquivo BLOQUEIA a página: é um `<script>` no `<head>`, antes do bundle. Um pacote gordo aqui
3
+ * custa em toda visita, inclusive na de quem nem vai entrar. O aviso vem antes de doer, e a recusa
4
+ * vem quando já dói para todo mundo.
5
+ */
6
+ export declare const STAMP_WARN_BYTES: number;
7
+ export declare const STAMP_MAX_BYTES: number;
8
+ export declare const STAMP_FILE = "microteia-extensions.js";
9
+ export interface LoginExtensionsResult {
10
+ /** O conteúdo do arquivo carimbado. Escrito sempre, mesmo vazio — ver `stampLoginExtensions`. */
11
+ contents: string;
12
+ /** `id@version` de cada pacote carimbado, na ordem declarada. */
13
+ stamped: string[];
14
+ /** O que quem instala precisa saber sem que o deploy pare. */
15
+ warnings: string[];
16
+ bytes: number;
17
+ }
18
+ export declare const parseExtensionPaths: (declared: string | null) => string[];
19
+ /**
20
+ * Monta o carimbo. `preLoginPoints` vem do manifesto do PACOTE do kernel instalado, e não de uma lista
21
+ * aqui: o Console é versionado separadamente, e uma cópia dela passaria a descrever o contrato de
22
+ * outra versão em silêncio. Ausente (pacote antigo), a checagem é pulada em vez de inventada.
23
+ */
24
+ export declare const buildLoginExtensions: (root: string, declared: string | null, preLoginPoints: string[] | undefined) => Promise<LoginExtensionsResult>;
25
+ /**
26
+ * Escreve o carimbo SEMPRE, inclusive vazio. Pular a escrita quando a lista está vazia deixaria o
27
+ * carimbo do deploy anterior no ar: tirar um pacote de `login.extensions` não o tiraria da tela.
28
+ */
29
+ export declare const stampPath: (install: string) => string;
@@ -0,0 +1,101 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ /**
4
+ * Este arquivo BLOQUEIA a página: é um `<script>` no `<head>`, antes do bundle. Um pacote gordo aqui
5
+ * custa em toda visita, inclusive na de quem nem vai entrar. O aviso vem antes de doer, e a recusa
6
+ * vem quando já dói para todo mundo.
7
+ */
8
+ export const STAMP_WARN_BYTES = 256 * 1024;
9
+ export const STAMP_MAX_BYTES = 1024 * 1024;
10
+ export const STAMP_FILE = 'microteia-extensions.js';
11
+ export const parseExtensionPaths = (declared) => (declared ?? '')
12
+ .split(',')
13
+ .map((entry) => entry.trim())
14
+ .filter((entry) => entry !== '');
15
+ const asStampedPackage = (parsed, path) => {
16
+ const manifest = parsed?.manifest;
17
+ if (!manifest || typeof manifest !== 'object') {
18
+ throw new Error(`${path} is not an extension package: it has no "manifest".`);
19
+ }
20
+ const { id, version, contributes } = manifest;
21
+ if (typeof id !== 'string' || id === '') {
22
+ throw new Error(`${path} has no "manifest.id".`);
23
+ }
24
+ if (typeof version !== 'string' || version === '') {
25
+ throw new Error(`${path} has no "manifest.version".`);
26
+ }
27
+ if (!Array.isArray(contributes)) {
28
+ throw new Error(`${path} has no "manifest.contributes" array.`);
29
+ }
30
+ return parsed;
31
+ };
32
+ /**
33
+ * Monta o carimbo. `preLoginPoints` vem do manifesto do PACOTE do kernel instalado, e não de uma lista
34
+ * aqui: o Console é versionado separadamente, e uma cópia dela passaria a descrever o contrato de
35
+ * outra versão em silêncio. Ausente (pacote antigo), a checagem é pulada em vez de inventada.
36
+ */
37
+ export const buildLoginExtensions = async (root, declared, preLoginPoints) => {
38
+ const paths = parseExtensionPaths(declared);
39
+ const packages = [];
40
+ const stamped = [];
41
+ const warnings = [];
42
+ for (const path of paths) {
43
+ const absolute = resolve(root, path);
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(await readFile(absolute, 'utf8'));
47
+ }
48
+ catch (error) {
49
+ if (error.code === 'ENOENT') {
50
+ throw new Error(`login.extensions lists ${path}, which does not exist.\n`
51
+ + 'Compile the extension first ("microteia-plugin build" inside extensions/), '
52
+ + 'or remove the path from microteia.yaml.', { cause: error });
53
+ }
54
+ throw new Error(`${path} is not valid JSON.`, { cause: error });
55
+ }
56
+ const pkg = asStampedPackage(parsed, path);
57
+ if (preLoginPoints !== undefined) {
58
+ const points = pkg.manifest.contributes
59
+ .map((contribution) => contribution.point)
60
+ .filter((point) => typeof point === 'string');
61
+ const effective = points.filter((point) => preLoginPoints.includes(point));
62
+ if (effective.length === 0) {
63
+ throw new Error(`${path} (${pkg.manifest.id}) contributes nothing that works before sign-in, so stamping it `
64
+ + 'would load it with no effect.\n'
65
+ + ` Points that work before sign-in: ${preLoginPoints.join(', ')}.\n`
66
+ + ` This package contributes: ${points.length === 0 ? '(none)' : [...new Set(points)].join(', ')}.\n`
67
+ + ' To USE it inside the organization, install it from the Extensions screen instead.');
68
+ }
69
+ }
70
+ if (Array.isArray(pkg.views) && pkg.views.length > 0) {
71
+ warnings.push(`${pkg.manifest.id} ships ${pkg.views.length} view(s): stamped packages load untrusted, `
72
+ + 'so their code does not run before sign-in — only declarations apply');
73
+ }
74
+ const duplicate = packages.find((existing) => existing.manifest.id === pkg.manifest.id);
75
+ if (duplicate !== undefined) {
76
+ throw new Error(`login.extensions lists ${pkg.manifest.id} twice. The Kernel would ignore the second one, `
77
+ + 'so the file says something the installation does not do.');
78
+ }
79
+ packages.push(pkg);
80
+ stamped.push(`${pkg.manifest.id}@${pkg.manifest.version}`);
81
+ }
82
+ const contents = `// Generated by "microteia deploy". Do not edit by hand.\n`
83
+ + `window.__MICROTEIA_EXTENSIONS__ = ${JSON.stringify(packages)};\n`;
84
+ const bytes = Buffer.byteLength(contents, 'utf8');
85
+ if (bytes > STAMP_MAX_BYTES) {
86
+ throw new Error(`The stamped extensions would be ${Math.round(bytes / 1024)} KiB, over the `
87
+ + `${STAMP_MAX_BYTES / 1024} KiB limit. This file blocks the page on every visit, including `
88
+ + 'visits by people who never sign in.\n'
89
+ + ' Keep before-sign-in packages to what the sign-in screen needs, and install the rest.');
90
+ }
91
+ if (bytes > STAMP_WARN_BYTES) {
92
+ warnings.push(`the stamped extensions are ${Math.round(bytes / 1024)} KiB, and this file blocks the page on `
93
+ + 'every visit');
94
+ }
95
+ return { contents, stamped, warnings, bytes };
96
+ };
97
+ /**
98
+ * Escreve o carimbo SEMPRE, inclusive vazio. Pular a escrita quando a lista está vazia deixaria o
99
+ * carimbo do deploy anterior no ar: tirar um pacote de `login.extensions` não o tiraria da tela.
100
+ */
101
+ export const stampPath = (install) => join(install, 'hosting', STAMP_FILE);
@@ -24,6 +24,18 @@ export interface ProvisionOptions {
24
24
  manifest: KernelManifest;
25
25
  delaySeconds: number;
26
26
  prune: boolean;
27
+ /** `--all`: ignora o carimbo e sobe toda function que as extensões instaladas exigem. */
28
+ all?: boolean;
29
+ /**
30
+ * O que as sondas do passo de functions do `deploy` observaram: carimbo antigo -> carimbo de
31
+ * agora. A function de extensão está no MESMO codebase da baseline, então esse carimbo vale para
32
+ * ela — e é por ele que uma cujo CÓDIGO mudou volta para a fila, coisa que o nome nunca disse.
33
+ *
34
+ * Sem ele — o `provision` chamado sozinho, logo depois de alguém instalar uma extensão pela
35
+ * aplicação — o comando decide como sempre decidiu. Sondar ali custaria um deploy a cada
36
+ * instalação de extensão para responder uma pergunta que naquele momento é quase sempre "nada".
37
+ */
38
+ refreshedStamps?: Map<string, string>;
27
39
  environmentFile: string;
28
40
  environment: Environment;
29
41
  }
@@ -10,9 +10,11 @@ import { changedParameters, functionsDeclaringParameters, parameterDigests, pars
10
10
  import { ENVIRONMENT_FILE, readEnvironment, writeEnvironment } from './environment.js';
11
11
  import { FIREBASE_BIN } from './firebaseCli.js';
12
12
  import { deployFunctionsOneByOne, reportFailures, reportNotices } from './deployFunctions.js';
13
- import { clearRegionOrphans } from './regionOrphans.js';
13
+ import { clearRegionOrphans, listDeployedFunctions } from './regionOrphans.js';
14
+ import { outdatedByStamp } from './functionsUpToDate.js';
14
15
  import { checkCallables, invokerGrantCommand } from './callableAccess.js';
15
- import { asString, asTimestamp, decodeFields, documentId, listDocuments, patchMapField } from './firestoreRest.js';
16
+ import { asString, asTimestamp, patchMapField } from './firestoreRest.js';
17
+ import { listTenants, readInstalledExtensions } from './installedExtensions.js';
16
18
  import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js';
17
19
  /**
18
20
  * O que cada escritório do projeto instalou e exige do backend.
@@ -22,27 +24,22 @@ import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js
22
24
  * explicação; não há injeção possível, porque o código é sempre o do pacote.
23
25
  */
24
26
  export const readInstalledClaims = async (projectId) => {
25
- const tenants = await listDocuments(projectId, '/tenants');
27
+ const tenants = await listTenants(projectId);
26
28
  const claims = [];
27
29
  for (const tenant of tenants) {
28
- const tenantId = documentId(tenant);
29
- const extensions = await listDocuments(projectId, `/tenants/${tenantId}/installed_extensions`);
30
- for (const document of extensions) {
31
- const data = decodeFields(document.fields);
32
- if (data.active === false)
30
+ for (const extension of await readInstalledExtensions(projectId, tenant.id)) {
31
+ if (!extension.active)
33
32
  continue;
34
- const pkg = data.package;
35
- const manifest = pkg?.manifest;
36
- const backend = manifest?.backend;
33
+ const backend = extension.manifest?.backend;
37
34
  if (!hasBackendRequirement(backend))
38
35
  continue;
39
- const state = data.backendState;
36
+ const state = extension.data.backendState;
40
37
  claims.push({
41
- tenantId,
42
- documentPath: `/tenants/${tenantId}/installed_extensions/${documentId(document)}`,
43
- pluginId: String(manifest?.id ?? documentId(document)),
44
- name: String(manifest?.name ?? manifest?.id ?? documentId(document)),
45
- version: String(manifest?.version ?? '0.0.0'),
38
+ tenantId: extension.tenantId,
39
+ documentPath: extension.documentPath,
40
+ pluginId: extension.id,
41
+ name: extension.name,
42
+ version: extension.version,
46
43
  backend: backend,
47
44
  applied: state?.fingerprint === backendFingerprint(backend),
48
45
  });
@@ -141,6 +138,28 @@ export const provisionBackend = async (options) => {
141
138
  const changed = changedParameters(recordedDigests, currentDigests, trackedParameters);
142
139
  const staleFunctions = new Set(functionsDeclaringParameters(valid, changed)
143
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);
144
+ // O que a lista de nomes nunca respondeu: a function aplicada que sumiu do projeto, e a que segue
145
+ // 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);
147
+ for (const name of [...outdated.absent, ...outdated.stale])
148
+ staleFunctions.add(name);
149
+ if (outdated.absent.length > 0) {
150
+ blank();
151
+ info(` ${outdated.absent.length} function(s) recorded as applied are not running in this project:`);
152
+ bullets(outdated.absent);
153
+ info(' they are deployed again — the record is a list of names, and a name does not know what');
154
+ info(' happened to the function after it went up.');
155
+ }
156
+ if (outdated.stale.length > 0) {
157
+ blank();
158
+ info(` ${outdated.stale.length} function(s) still carry the code of a previous package:`);
159
+ bullets(outdated.stale);
160
+ info(' Firebase stamps every function with what it was built from, and theirs no longer matches');
161
+ info(' what this package builds. They are deployed again.');
162
+ }
144
163
  if (changed.length > 0) {
145
164
  blank();
146
165
  info(` ${changed.length} backend setting(s) changed since the last run: ${changed.join(', ')}`);
@@ -161,7 +180,7 @@ export const provisionBackend = async (options) => {
161
180
  info(' Cloud Function at ITS deploy, so a function already running keeps the previous one.');
162
181
  blank();
163
182
  }
164
- const toDeploy = wantedFunctions.filter((name) => !alreadyApplied.includes(name) || staleFunctions.has(name));
183
+ const toDeploy = wantedFunctions.filter((name) => options.all === true || !alreadyApplied.includes(name) || staleFunctions.has(name));
165
184
  if (toDeploy.length === 0) {
166
185
  success(`the ${wantedFunctions.length} function(s) these extensions need are already deployed`);
167
186
  }
@@ -180,7 +199,7 @@ export const provisionBackend = async (options) => {
180
199
  }
181
200
  // A extensão que mudou de região deixa a cópia antiga para trás igual ao kernel, e ali o efeito
182
201
  // é o mesmo: deploy recusado em toda tentativa, e gatilho disparando em duas regiões.
183
- await clearRegionOrphans(projectId, toDeploy, region, options.prune);
202
+ await clearRegionOrphans(projectId, toDeploy, region, options.prune, deployedFunctions);
184
203
  info(` deploying ${toDeploy.length} function(s), one at a time…`);
185
204
  const report = await deployFunctionsOneByOne(toDeploy, {
186
205
  projectId, cwd: install, delaySeconds, force: false,
@@ -456,6 +475,7 @@ export const provision = async (args) => {
456
475
  manifest,
457
476
  delaySeconds,
458
477
  prune: hasFlag(args, 'prune'),
478
+ all: hasFlag(args, 'all'),
459
479
  environmentFile: file,
460
480
  environment,
461
481
  });
@@ -1,6 +1,21 @@
1
1
  export interface DeployedFunction {
2
2
  name: string;
3
3
  region: string;
4
+ /**
5
+ * O carimbo que o PRÓPRIO `firebase` grava na function, no label `firebase-functions-hash`:
6
+ * sha1 do diretório de fontes inteiro, mais as variáveis de ambiente resolvidas, mais as versões
7
+ * de segredo. É o valor que ele compara consigo mesmo para pular uma function que não mudou num
8
+ * deploy em lote (`calculateChangesets`), e é por isso que ele serve aqui: comparar por ele é
9
+ * tomar a MESMA decisão que o `firebase deploy` tomaria, não uma parecida.
10
+ *
11
+ * `null` quando a function foi deployada por uma versão do CLI que não gravava o label — nesse
12
+ * caso não há o que comparar, e ela sobe.
13
+ */
14
+ hash: string | null;
15
+ /** `ACTIVE` é a única resposta que autoriza comparar: o resto está no meio de alguma coisa. */
16
+ state: string;
17
+ /** `gcfv1` e `gcfv2` empacotam fontes diferentes, então carimbam hashes diferentes. */
18
+ platform: string;
4
19
  }
5
20
  /**
6
21
  * A cópia da mesma function numa região que o pacote não usa mais.
@@ -40,4 +55,10 @@ export declare const settleRegionOrphans: (projectId: string, orphans: RegionOrp
40
55
  * O passo inteiro, para quem só quer chamá-lo antes de deployar. Devolve `true` quando o caminho está
41
56
  * livre — inclusive quando não deu para verificar, pela mesma razão de `listDeployedFunctions`.
42
57
  */
43
- export declare const clearRegionOrphans: (projectId: string, wanted: string[], region: string, prune: boolean) => Promise<boolean>;
58
+ export declare const clearRegionOrphans: (projectId: string, wanted: string[], region: string, prune: boolean,
59
+ /**
60
+ * A listagem já feita, quando quem chama vai usá-la para outra coisa também. Perguntar duas vezes
61
+ * a mesma coisa ao mesmo projeto não é caro, é ambíguo: as duas respostas podem divergir, e a
62
+ * decisão passa a depender de qual delas o código olhou.
63
+ */
64
+ listed?: DeployedFunction[] | null) => Promise<boolean>;
@@ -17,7 +17,13 @@ export const listDeployedFunctions = async (projectId) => {
17
17
  return null;
18
18
  return parsed.result
19
19
  .filter((entry) => entry !== null && typeof entry === 'object')
20
- .map((entry) => ({ name: String(entry.id ?? ''), region: String(entry.region ?? '') }))
20
+ .map((entry) => ({
21
+ name: String(entry.id ?? ''),
22
+ region: String(entry.region ?? ''),
23
+ hash: typeof entry.hash === 'string' && entry.hash !== '' ? entry.hash : null,
24
+ state: typeof entry.state === 'string' ? entry.state : '',
25
+ platform: typeof entry.platform === 'string' ? entry.platform : '',
26
+ }))
21
27
  .filter((entry) => entry.name !== '' && entry.region !== '');
22
28
  }
23
29
  catch {
@@ -91,8 +97,14 @@ export const settleRegionOrphans = async (projectId, orphans, prune) => {
91
97
  * O passo inteiro, para quem só quer chamá-lo antes de deployar. Devolve `true` quando o caminho está
92
98
  * livre — inclusive quando não deu para verificar, pela mesma razão de `listDeployedFunctions`.
93
99
  */
94
- export const clearRegionOrphans = async (projectId, wanted, region, prune) => {
95
- const deployed = await listDeployedFunctions(projectId);
100
+ export const clearRegionOrphans = async (projectId, wanted, region, prune,
101
+ /**
102
+ * A listagem já feita, quando quem chama vai usá-la para outra coisa também. Perguntar duas vezes
103
+ * a mesma coisa ao mesmo projeto não é caro, é ambíguo: as duas respostas podem divergir, e a
104
+ * decisão passa a depender de qual delas o código olhou.
105
+ */
106
+ listed) => {
107
+ const deployed = listed === undefined ? await listDeployedFunctions(projectId) : listed;
96
108
  if (deployed === null)
97
109
  return true;
98
110
  return settleRegionOrphans(projectId, regionOrphans(deployed, wanted, region), prune);