@oondemand/oon-core-back 0.7.17 → 0.7.20
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/package.json +1 -1
- package/src/api/access.routes.js +6 -0
- package/src/api/activation.routes.js +2 -19
- package/src/api/activationRateLimit.js +84 -0
- package/src/boot/database.js +45 -12
- package/src/boot/gracefulShutdown.js +64 -0
- package/src/boot/startServer.js +26 -3
- package/src/capabilities/transactionalEmail/models.js +3 -1
- package/src/capabilities/transactionalEmail/worker.js +9 -2
- package/src/core/database/policy.js +17 -1
- package/src/core/middlewares/auth.js +17 -0
- package/src/core/runtime/tenantDataPurge.js +166 -0
- package/src/core/storage/files.js +131 -0
- package/src/core/usage/reporter.js +193 -0
- package/src/core/utils/instanceActivation.js +20 -10
- package/src/delivery/runtime.js +153 -6
- package/src/delivery/supervisor.js +15 -4
- package/src/index.js +4 -0
- package/src/manifest/appManifest.js +4 -1
- package/src/manifest/processJobs.js +252 -0
- package/src/manifest/processRuntime.js +52 -4
- package/src/manifest/usageMetrics.js +39 -0
|
@@ -5,6 +5,7 @@ const path = require("node:path");
|
|
|
5
5
|
const ctx = require("../core/context");
|
|
6
6
|
const corePackage = require("../../package.json");
|
|
7
7
|
const { validateRbac } = require("./rbacManifest");
|
|
8
|
+
const { validateUsageMetrics } = require("./usageMetrics");
|
|
8
9
|
const { deriveDeploymentMode, resolveAppArchitecture } = require("./appArchitecture");
|
|
9
10
|
|
|
10
11
|
const APP_MANIFEST_FILENAME = "central.app.json";
|
|
@@ -214,7 +215,7 @@ function validateAppManifest(input, { coreVersion = corePackage.version } = {})
|
|
|
214
215
|
const allowedTopLevel = new Set([
|
|
215
216
|
"$schema", "schemaVersion", "id", "name", "slug", "appKind",
|
|
216
217
|
"tenancyModel", "availability", "accessMode", "authorizationMode", "deploymentMode",
|
|
217
|
-
"modules", "capabilities", "capabilitySettings", "compatibility", "activation", "auth", "rbac",
|
|
218
|
+
"modules", "capabilities", "capabilitySettings", "compatibility", "activation", "auth", "rbac", "usageMetrics",
|
|
218
219
|
]);
|
|
219
220
|
for (const key of Object.keys(input)) {
|
|
220
221
|
if (!allowedTopLevel.has(key)) addIssue(issues, key, "propriedade não suportada.");
|
|
@@ -315,6 +316,7 @@ function validateAppManifest(input, { coreVersion = corePackage.version } = {})
|
|
|
315
316
|
}
|
|
316
317
|
}
|
|
317
318
|
validateRbac(input.rbac, issues);
|
|
319
|
+
validateUsageMetrics(input.usageMetrics, issues);
|
|
318
320
|
if (issues.length) throw new AppManifestError("central.app.json inválido.", issues);
|
|
319
321
|
return JSON.parse(JSON.stringify(input));
|
|
320
322
|
}
|
|
@@ -350,6 +352,7 @@ function appManifestToConfig(manifest, manifestPath) {
|
|
|
350
352
|
capabilitySettings: JSON.parse(JSON.stringify(manifest.capabilitySettings || {})),
|
|
351
353
|
auth: manifest.auth ? JSON.parse(JSON.stringify(manifest.auth)) : undefined,
|
|
352
354
|
rbac: manifest.rbac ? JSON.parse(JSON.stringify(manifest.rbac)) : undefined,
|
|
355
|
+
usageMetrics: manifest.usageMetrics ? JSON.parse(JSON.stringify(manifest.usageMetrics)) : [],
|
|
353
356
|
appManifest: manifest,
|
|
354
357
|
appManifestPath: manifestPath,
|
|
355
358
|
};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fila persistida dos recálculos assíncronos (`recalculate: "async"`).
|
|
5
|
+
*
|
|
6
|
+
* Antes a fila ficava só na memória do processo: tarefas pendentes se perdiam
|
|
7
|
+
* quando a réplica reiniciava, e duas réplicas podiam recalcular o mesmo
|
|
8
|
+
* registro ao mesmo tempo. Agora:
|
|
9
|
+
* - cada tarefa é um documento em `oon_process_jobs`; dentro de uma transação
|
|
10
|
+
* de domínio ela é gravada na mesma sessão, então só existe se a mudança que
|
|
11
|
+
* a originou for confirmada;
|
|
12
|
+
* - uma réplica reserva a tarefa com lease (como o worker de e-mail) e, se o
|
|
13
|
+
* processo morrer, outra réplica a retoma quando o lease expira;
|
|
14
|
+
* - uma trava por alvo (`oon_process_job_locks`) impede duas réplicas de
|
|
15
|
+
* recalcular o mesmo conjunto de registros ao mesmo tempo;
|
|
16
|
+
* - tarefas pendentes iguais, já visíveis quando o recálculo começa, são
|
|
17
|
+
* cobertas por ele e removidas juntas (deduplicação).
|
|
18
|
+
*/
|
|
19
|
+
const crypto = require("node:crypto");
|
|
20
|
+
const mongoose = require("../core/database/mongoose");
|
|
21
|
+
|
|
22
|
+
const JOBS = "oon_process_jobs";
|
|
23
|
+
const LOCKS = "oon_process_job_locks";
|
|
24
|
+
const LEASE_MS = 60000;
|
|
25
|
+
const MAX_ATTEMPTS = 5;
|
|
26
|
+
const TICK_MS = 1000;
|
|
27
|
+
|
|
28
|
+
const state = {
|
|
29
|
+
owner: `${process.pid}:${crypto.randomUUID()}`,
|
|
30
|
+
running: false,
|
|
31
|
+
timer: null,
|
|
32
|
+
inFlight: null,
|
|
33
|
+
ready: null,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function stableValue(value) {
|
|
37
|
+
if (value === null || value === undefined) return value ?? null;
|
|
38
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
39
|
+
if (value instanceof Date) return value.toISOString();
|
|
40
|
+
if (typeof value === "object") {
|
|
41
|
+
if (typeof value.toHexString === "function") return value.toHexString();
|
|
42
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function dedupeKey(modelName, selectors, accessContext) {
|
|
48
|
+
const material = JSON.stringify(stableValue({ modelName, selectors, accessContext: accessContext || null }));
|
|
49
|
+
return crypto.createHash("sha256").update(material).digest("hex");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function db(connection = mongoose.connection) {
|
|
53
|
+
return connection?.readyState === 1 ? connection : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function ensureReady(connection = db()) {
|
|
57
|
+
if (!connection) return false;
|
|
58
|
+
if (!state.ready) {
|
|
59
|
+
state.ready = Promise.all([
|
|
60
|
+
connection.collection(JOBS).createIndex({ status: 1, nextRunAt: 1 }),
|
|
61
|
+
connection.collection(JOBS).createIndex({ dedupeKey: 1, status: 1 }),
|
|
62
|
+
connection.collection(LOCKS).createIndex({ expiresAt: 1 }),
|
|
63
|
+
]).catch((error) => {
|
|
64
|
+
state.ready = null;
|
|
65
|
+
throw error;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
await state.ready;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function available() {
|
|
73
|
+
return Boolean(db());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function enqueue({ modelName, selectors, accessContext, session } = {}) {
|
|
77
|
+
const connection = db();
|
|
78
|
+
if (!connection) throw new Error("Fila persistida indisponível sem conexão com o banco.");
|
|
79
|
+
// Índices e coleção são criados fora da transação; dentro dela só o insert.
|
|
80
|
+
await ensureReady(connection);
|
|
81
|
+
const now = new Date();
|
|
82
|
+
await connection.collection(JOBS).insertOne({
|
|
83
|
+
model: modelName,
|
|
84
|
+
selectors,
|
|
85
|
+
accessContext: accessContext || null,
|
|
86
|
+
dedupeKey: dedupeKey(modelName, selectors, accessContext),
|
|
87
|
+
status: "pending",
|
|
88
|
+
attempts: 0,
|
|
89
|
+
nextRunAt: now,
|
|
90
|
+
createdAt: now,
|
|
91
|
+
}, session ? { session } : {});
|
|
92
|
+
if (!session) kick();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function lease(connection) {
|
|
96
|
+
const now = new Date();
|
|
97
|
+
const result = await connection.collection(JOBS).findOneAndUpdate(
|
|
98
|
+
{
|
|
99
|
+
$or: [
|
|
100
|
+
{ status: "pending", nextRunAt: { $lte: now } },
|
|
101
|
+
{ status: "processing", leaseExpiresAt: { $lte: now } },
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
$set: { status: "processing", leaseOwner: state.owner, leaseExpiresAt: new Date(now.getTime() + LEASE_MS) },
|
|
106
|
+
$inc: { attempts: 1 },
|
|
107
|
+
},
|
|
108
|
+
{ sort: { nextRunAt: 1, createdAt: 1 }, returnDocument: "after" },
|
|
109
|
+
);
|
|
110
|
+
return result && Object.hasOwn(result, "value") ? result.value : result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function acquireLock(connection, key) {
|
|
114
|
+
const locks = connection.collection(LOCKS);
|
|
115
|
+
const expiresAt = new Date(Date.now() + LEASE_MS);
|
|
116
|
+
try {
|
|
117
|
+
await locks.insertOne({ _id: key, owner: state.owner, expiresAt });
|
|
118
|
+
return true;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (error?.code !== 11000) throw error;
|
|
121
|
+
}
|
|
122
|
+
const takeover = await locks.updateOne(
|
|
123
|
+
{ _id: key, expiresAt: { $lte: new Date() } },
|
|
124
|
+
{ $set: { owner: state.owner, expiresAt } },
|
|
125
|
+
);
|
|
126
|
+
return Number(takeover?.modifiedCount || 0) > 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function releaseLock(connection, key) {
|
|
130
|
+
await connection.collection(LOCKS).deleteOne({ _id: key, owner: state.owner });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function backoff(attempts) {
|
|
134
|
+
return Math.min(300000, 1000 * (2 ** Math.max(0, attempts - 1)));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Processa uma tarefa. Retorna `false` quando não há tarefa disponível agora.
|
|
139
|
+
* `run(modelName, selectors, accessContext)` executa o recálculo.
|
|
140
|
+
*/
|
|
141
|
+
async function processOne(run, connection = db()) {
|
|
142
|
+
if (!connection) return false;
|
|
143
|
+
await ensureReady(connection);
|
|
144
|
+
const jobs = connection.collection(JOBS);
|
|
145
|
+
const job = await lease(connection);
|
|
146
|
+
if (!job) return false;
|
|
147
|
+
|
|
148
|
+
if (!await acquireLock(connection, job.dedupeKey)) {
|
|
149
|
+
// Outra réplica está recalculando o mesmo alvo: tenta de novo em seguida.
|
|
150
|
+
await jobs.updateOne(
|
|
151
|
+
{ _id: job._id, leaseOwner: state.owner },
|
|
152
|
+
{ $set: { status: "pending", nextRunAt: new Date(Date.now() + 500) }, $unset: { leaseOwner: "", leaseExpiresAt: "" }, $inc: { attempts: -1 } },
|
|
153
|
+
);
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
// Pendentes iguais já visíveis agora são cobertas por este recálculo, que lê o estado atual.
|
|
158
|
+
const siblings = await jobs.find(
|
|
159
|
+
{ dedupeKey: job.dedupeKey, status: "pending", _id: { $ne: job._id } },
|
|
160
|
+
{ projection: { _id: 1 } },
|
|
161
|
+
).toArray();
|
|
162
|
+
try {
|
|
163
|
+
await run(job.model, job.selectors, job.accessContext || undefined);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
const failed = job.attempts >= MAX_ATTEMPTS;
|
|
166
|
+
await jobs.updateOne(
|
|
167
|
+
{ _id: job._id, leaseOwner: state.owner },
|
|
168
|
+
{
|
|
169
|
+
$set: failed
|
|
170
|
+
? { status: "failed", failedAt: new Date(), lastError: String(error?.message || error) }
|
|
171
|
+
: { status: "pending", nextRunAt: new Date(Date.now() + backoff(job.attempts)), lastError: String(error?.message || error) },
|
|
172
|
+
$unset: { leaseOwner: "", leaseExpiresAt: "" },
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
console.error("OON_PROCESS_ASYNC_RECALCULATION_FAILED", { model: job.model, attempts: job.attempts, final: failed, message: error?.message });
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
await jobs.deleteOne({ _id: job._id, leaseOwner: state.owner });
|
|
179
|
+
if (siblings.length) await jobs.deleteMany({ _id: { $in: siblings.map((doc) => doc._id) }, status: "pending" });
|
|
180
|
+
return true;
|
|
181
|
+
} finally {
|
|
182
|
+
await releaseLock(connection, job.dedupeKey);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let runner = null;
|
|
187
|
+
|
|
188
|
+
function setRunner(fn) {
|
|
189
|
+
runner = fn;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function drainAvailable() {
|
|
193
|
+
if (!runner) return;
|
|
194
|
+
// eslint-disable-next-line no-await-in-loop
|
|
195
|
+
while (await processOne(runner)) { /* continua até não haver tarefa disponível */ }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function runExclusive() {
|
|
199
|
+
if (state.inFlight) return state.inFlight;
|
|
200
|
+
state.inFlight = drainAvailable()
|
|
201
|
+
.catch((error) => console.error("OON_PROCESS_JOBS_WORKER_FAILED", { message: error?.message }))
|
|
202
|
+
.finally(() => { state.inFlight = null; });
|
|
203
|
+
return state.inFlight;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function schedule(delay = TICK_MS) {
|
|
207
|
+
if (!state.running) return;
|
|
208
|
+
clearTimeout(state.timer);
|
|
209
|
+
state.timer = setTimeout(() => { void runExclusive().finally(() => schedule()); }, delay);
|
|
210
|
+
state.timer.unref?.();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function kick() {
|
|
214
|
+
if (state.running) schedule(0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function start() {
|
|
218
|
+
if (state.running || !available()) return false;
|
|
219
|
+
await ensureReady();
|
|
220
|
+
state.running = true;
|
|
221
|
+
schedule(10);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function stop() {
|
|
226
|
+
state.running = false;
|
|
227
|
+
clearTimeout(state.timer);
|
|
228
|
+
state.timer = null;
|
|
229
|
+
if (state.inFlight) await state.inFlight;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Processa todas as tarefas disponíveis agora e espera a que estiver em andamento. */
|
|
233
|
+
async function drain() {
|
|
234
|
+
if (state.inFlight) await state.inFlight;
|
|
235
|
+
await runExclusive();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = {
|
|
239
|
+
JOBS,
|
|
240
|
+
LOCKS,
|
|
241
|
+
MAX_ATTEMPTS,
|
|
242
|
+
available,
|
|
243
|
+
dedupeKey,
|
|
244
|
+
drain,
|
|
245
|
+
enqueue,
|
|
246
|
+
ensureReady,
|
|
247
|
+
processOne,
|
|
248
|
+
setRunner,
|
|
249
|
+
start,
|
|
250
|
+
state,
|
|
251
|
+
stop,
|
|
252
|
+
};
|
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
roundValue,
|
|
11
11
|
valuesEquivalent,
|
|
12
12
|
} = require("./domainRules");
|
|
13
|
+
const processJobs = require("./processJobs");
|
|
13
14
|
|
|
14
15
|
const MAX_AUTOMATIC_TRANSITIONS = 8;
|
|
15
16
|
const MAX_TRANSACTION_ATTEMPTS = 3;
|
|
@@ -535,11 +536,29 @@ async function recalculateTarget(entry, selectors, options = {}) {
|
|
|
535
536
|
return { matched: documents.length, modified: result.modifiedCount || 0 };
|
|
536
537
|
}
|
|
537
538
|
|
|
538
|
-
function
|
|
539
|
+
function frozenAccessContext(accessContext) {
|
|
539
540
|
// Capture a value, never a mutable request/session or the next user's context.
|
|
540
|
-
|
|
541
|
-
permissions: Object.freeze([...(
|
|
541
|
+
return accessContext ? Object.freeze({ ...accessContext,
|
|
542
|
+
permissions: Object.freeze([...(accessContext.permissions || [])]) }) : undefined;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function plainAccessContext(accessContext) {
|
|
546
|
+
return accessContext ? { ...accessContext, permissions: [...(accessContext.permissions || [])] } : undefined;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Com banco conectado, a fila é persistida (processJobs): sobrevive a reinícios
|
|
550
|
+
// e é compartilhada entre réplicas. Sem banco, mantém a fila em memória.
|
|
551
|
+
function enqueueAsynchronousRecalculation(target, options) {
|
|
552
|
+
const accessContext = frozenAccessContext(options.accessContext);
|
|
542
553
|
scopeFilter(target.entry, accessContext);
|
|
554
|
+
if (processJobs.available()) {
|
|
555
|
+
return processJobs.enqueue({
|
|
556
|
+
modelName: target.entry.name,
|
|
557
|
+
selectors: target.selectors,
|
|
558
|
+
accessContext: plainAccessContext(accessContext),
|
|
559
|
+
session: options.session?.inTransaction?.() ? options.session : undefined,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
543
562
|
asynchronousQueue = asynchronousQueue
|
|
544
563
|
.then(() => new Promise((resolve) => setImmediate(resolve)))
|
|
545
564
|
.then(() => recalculateTarget(target.entry, target.selectors, { session: null, accessContext }))
|
|
@@ -565,11 +584,16 @@ async function recalculateDependents(entry, before, after, options = {}) {
|
|
|
565
584
|
const results = [];
|
|
566
585
|
for (const target of targets) {
|
|
567
586
|
if (target.mode === "async") {
|
|
568
|
-
if (options.session?.inTransaction()) {
|
|
587
|
+
if (options.session?.inTransaction() && processJobs.available()) {
|
|
588
|
+
// Gravada na mesma transação: a tarefa só existe se a mudança for confirmada.
|
|
589
|
+
await enqueueAsynchronousRecalculation(target, options);
|
|
590
|
+
} else if (options.session?.inTransaction()) {
|
|
569
591
|
const jobs = transactionJobs.get(options.session);
|
|
570
592
|
if (!jobs) throw new DomainRuleError("Fila assíncrona exige transação gerenciada.", { code: "OON_PROCESS_TRANSACTION_REQUIRED", statusCode: 409 });
|
|
571
593
|
scopeFilter(target.entry, options.accessContext);
|
|
572
594
|
jobs.push({ target, options: { accessContext: options.accessContext ? { ...options.accessContext } : undefined } });
|
|
595
|
+
} else if (processJobs.available()) {
|
|
596
|
+
await enqueueAsynchronousRecalculation(target, options);
|
|
573
597
|
} else enqueueAsynchronousRecalculation(target, options);
|
|
574
598
|
results.push({ model: target.entry.name, mode: "async" });
|
|
575
599
|
} else {
|
|
@@ -583,8 +607,30 @@ async function recalculateDependents(entry, before, after, options = {}) {
|
|
|
583
607
|
return results;
|
|
584
608
|
}
|
|
585
609
|
|
|
610
|
+
processJobs.setRunner((modelName, selectors, accessContext) => {
|
|
611
|
+
const entry = registry.getModel(modelName);
|
|
612
|
+
if (!entry) throw new Error(`Model ${modelName} não registrada para o recálculo assíncrono.`);
|
|
613
|
+
return recalculateTarget(entry, selectors, { session: null, accessContext });
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
/** Processa as tarefas assíncronas disponíveis agora (fila persistida e em memória). */
|
|
586
617
|
async function drainProcessJobs() {
|
|
587
618
|
await asynchronousQueue;
|
|
619
|
+
if (processJobs.available()) await processJobs.drain();
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Inicia o worker da fila persistida; chamado no boot, depois de conectar ao banco. */
|
|
623
|
+
function startProcessJobs() {
|
|
624
|
+
return processJobs.start();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Encerramento: para o worker persistido (as pendentes ficam para outra
|
|
629
|
+
* réplica) e espera a fila em memória e a tarefa em andamento.
|
|
630
|
+
*/
|
|
631
|
+
async function stopProcessJobs() {
|
|
632
|
+
await processJobs.stop();
|
|
633
|
+
await asynchronousQueue;
|
|
588
634
|
}
|
|
589
635
|
|
|
590
636
|
module.exports = {
|
|
@@ -601,5 +647,7 @@ module.exports = {
|
|
|
601
647
|
recalculateDependents,
|
|
602
648
|
recalculateTarget,
|
|
603
649
|
drainProcessJobs,
|
|
650
|
+
startProcessJobs,
|
|
651
|
+
stopProcessJobs,
|
|
604
652
|
toPlainRecord,
|
|
605
653
|
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// F5.2: métricas de uso que o App declara no central.app.json (`usageMetrics`).
|
|
4
|
+
// A Central lê em GET /api/core/usage-metrics para pré-preencher os limites
|
|
5
|
+
// do plano do App (Revenda > Planos), como faz com o catálogo de perfis.
|
|
6
|
+
const METRIC = /^[a-z][a-z0-9_.-]{1,62}$/;
|
|
7
|
+
const MAX_METRICS = 10;
|
|
8
|
+
|
|
9
|
+
function validateUsageMetrics(input, issues) {
|
|
10
|
+
if (input === undefined) return;
|
|
11
|
+
const add = (path, message) => issues.push({ path, message });
|
|
12
|
+
if (!Array.isArray(input) || input.length > MAX_METRICS) return add("usageMetrics", `deve ser uma lista com até ${MAX_METRICS} métricas.`);
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
input.forEach((metric, index) => {
|
|
15
|
+
const path = `usageMetrics[${index}]`;
|
|
16
|
+
if (!metric || typeof metric !== "object" || Array.isArray(metric)) return add(path, "deve ser um objeto.");
|
|
17
|
+
for (const key of Object.keys(metric)) if (!["code", "name", "unit", "description", "defaults"].includes(key)) add(`${path}.${key}`, "propriedade não suportada.");
|
|
18
|
+
if (typeof metric.code !== "string" || !METRIC.test(metric.code)) add(`${path}.code`, "use letras minúsculas, números, ponto, hífen ou sublinhado.");
|
|
19
|
+
else if (seen.has(metric.code)) add(`${path}.code`, "código repetido."); else seen.add(metric.code);
|
|
20
|
+
if (typeof metric.name !== "string" || !metric.name.trim() || metric.name.length > 80) add(`${path}.name`, "obrigatório, até 80 caracteres.");
|
|
21
|
+
const d = metric.defaults;
|
|
22
|
+
if (d === undefined) return;
|
|
23
|
+
if (!d || typeof d !== "object") return add(`${path}.defaults`, "deve ser um objeto.");
|
|
24
|
+
if (d.included !== undefined && (!Number.isSafeInteger(d.included) || d.included < 0)) add(`${path}.defaults.included`, "inteiro maior ou igual a zero.");
|
|
25
|
+
if (d.onLimit !== undefined && !["block", "auto_addon"].includes(d.onLimit)) add(`${path}.defaults.onLimit`, "block ou auto_addon.");
|
|
26
|
+
if (d.overage !== undefined) {
|
|
27
|
+
const o = d.overage || {};
|
|
28
|
+
if (!["unit", "block"].includes(o.mode)) add(`${path}.defaults.overage.mode`, "unit ou block.");
|
|
29
|
+
if (!Number.isSafeInteger(o.priceMinor) || o.priceMinor < 1) add(`${path}.defaults.overage.priceMinor`, "valor em centavos, maior que zero.");
|
|
30
|
+
if (o.mode === "block" && (!Number.isSafeInteger(o.blockSize) || o.blockSize < 1)) add(`${path}.defaults.overage.blockSize`, "tamanho do bloco, maior que zero.");
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function publicUsageMetrics(config) {
|
|
36
|
+
return { schemaVersion: 1, appCode: String(config.appCode || config.slug || "central"), metrics: JSON.parse(JSON.stringify(config.usageMetrics || [])) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { validateUsageMetrics, publicUsageMetrics, MAX_METRICS };
|