@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.
@@ -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 enqueueAsynchronousRecalculation(target, options) {
539
+ function frozenAccessContext(accessContext) {
539
540
  // Capture a value, never a mutable request/session or the next user's context.
540
- const accessContext = options.accessContext ? Object.freeze({ ...options.accessContext,
541
- permissions: Object.freeze([...(options.accessContext.permissions || [])]) }) : undefined;
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 };