@oondemand/oon-core-back 0.7.16 → 0.7.19

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.
@@ -38,7 +38,40 @@ function readSecret(namespace, name, key) {
38
38
  } catch { return null; }
39
39
  }
40
40
 
41
+ // Piso da cota do namespace (valores anteriores, fixos para todo App).
42
+ const QUOTA_FLOOR = Object.freeze({ requestsCpuMilli: 2000, requestsMemoryMi: 2048, limitsCpuMilli: 4000, limitsMemoryMi: 4096, pods: 20 });
43
+
44
+ function cpuMilli(value) {
45
+ const text = String(value);
46
+ return text.endsWith("m") ? Number(text.slice(0, -1)) : Number(text) * 1000;
47
+ }
48
+
49
+ function memoryMi(value) {
50
+ const text = String(value);
51
+ if (text.endsWith("Gi")) return Number(text.slice(0, -2)) * 1024;
52
+ if (text.endsWith("Mi")) return Number(text.slice(0, -2));
53
+ throw new Error(`Unidade de memória não suportada: ${text}`);
54
+ }
55
+
56
+ /**
57
+ * Cota do namespace calculada pelas réplicas e pelo perfil de recursos, com
58
+ * espaço para o pod extra do rolling update (maxSurge 1). Nunca fica abaixo
59
+ * do piso anterior, então Apps de 1 réplica mantêm a cota de sempre.
60
+ */
61
+ function namespaceQuota({ replicas = 1, profile = "small" } = {}) {
62
+ const resources = RESOURCE_PROFILES[profile] || RESOURCE_PROFILES.small;
63
+ const pods = replicas + 1;
64
+ return {
65
+ requestsCpu: `${Math.max(QUOTA_FLOOR.requestsCpuMilli, pods * cpuMilli(resources.cpuRequest))}m`,
66
+ requestsMemory: `${Math.max(QUOTA_FLOOR.requestsMemoryMi, pods * memoryMi(resources.memoryRequest))}Mi`,
67
+ limitsCpu: `${Math.max(QUOTA_FLOOR.limitsCpuMilli, pods * cpuMilli(resources.cpuLimit))}m`,
68
+ limitsMemory: `${Math.max(QUOTA_FLOOR.limitsMemoryMi, pods * memoryMi(resources.memoryLimit))}Mi`,
69
+ pods: Math.max(QUOTA_FLOOR.pods, pods),
70
+ };
71
+ }
72
+
41
73
  function baselineManifest({ context }) {
74
+ const quota = namespaceQuota({ replicas: replicaCount(context), profile: context.resourceProfile });
42
75
  return `apiVersion: v1
43
76
  kind: Namespace
44
77
  metadata:
@@ -66,7 +99,7 @@ apiVersion: v1
66
99
  kind: ResourceQuota
67
100
  metadata: { name: oon-runtime-quota, namespace: ${context.namespace} }
68
101
  spec:
69
- hard: { requests.cpu: "2", requests.memory: 2Gi, limits.cpu: "4", limits.memory: 4Gi, pods: "20", services: "10" }
102
+ hard: { requests.cpu: "${quota.requestsCpu}", requests.memory: ${quota.requestsMemory}, limits.cpu: "${quota.limitsCpu}", limits.memory: ${quota.limitsMemory}, pods: "${quota.pods}", services: "10" }
70
103
  ---
71
104
  apiVersion: networking.k8s.io/v1
72
105
  kind: NetworkPolicy
@@ -83,8 +116,74 @@ spec:
83
116
  `;
84
117
  }
85
118
 
119
+ // Cada slot contratado é uma réplica. O teto acompanha a cota de pods do
120
+ // namespace (baselineManifest), com folga para o pod extra do rolling update.
121
+ const MAX_REPLICAS = 10;
122
+ // preStop + OON_SHUTDOWN_GRACE_MS do supervisor (25 s) cabem neste prazo.
123
+ const PRE_STOP_SECONDS = 5;
124
+ const TERMINATION_GRACE_SECONDS = 40;
125
+
126
+ function replicaCount(context = {}) {
127
+ const value = context.replicas ?? 1;
128
+ if (!Number.isSafeInteger(value) || value < 1 || value > MAX_REPLICAS) {
129
+ throw new Error(`Número de réplicas inválido no contexto de delivery: ${JSON.stringify(value)} (esperado 1 a ${MAX_REPLICAS}).`);
130
+ }
131
+ return value;
132
+ }
133
+
134
+ // F4.2: modo de performance do ambiente (pool MongoDB 4, 8 ou 16).
135
+ const PERFORMANCE_MODES = ["min", "normal", "ultra"];
136
+ function performanceMode(context = {}) {
137
+ const value = context.performanceMode ?? "min";
138
+ if (!PERFORMANCE_MODES.includes(value)) {
139
+ throw new Error(`Modo de performance inválido no contexto de delivery: ${JSON.stringify(value)} (esperado ${PERFORMANCE_MODES.join(", ")}).`);
140
+ }
141
+ return value;
142
+ }
143
+
144
+ // F4.6: domínios personalizados do App (raiz e www), já com DNS aprovado na
145
+ // Central. Ficam num Ingress próprio, com certificado HTTP-01 do cert-manager
146
+ // (renovação automática), para que um domínio com problema não afete o host
147
+ // da plataforma.
148
+ const MAX_CUSTOM_DOMAINS = 10;
149
+ const HOSTNAME = /^(?=.{4,253}$)(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/;
150
+ function customDomains(context = {}) {
151
+ const value = context.customDomains ?? [];
152
+ if (!Array.isArray(value) || value.length > MAX_CUSTOM_DOMAINS
153
+ || value.some((host) => typeof host !== "string" || !HOSTNAME.test(host) || host === context.hostname)
154
+ || new Set(value).size !== value.length) {
155
+ throw new Error(`Domínios personalizados inválidos no contexto de delivery: ${JSON.stringify(value)}.`);
156
+ }
157
+ return value;
158
+ }
159
+ function customDomainIngress({ context, infra }) {
160
+ const hosts = customDomains(context);
161
+ if (!hosts.length) return "";
162
+ return `---
163
+ apiVersion: networking.k8s.io/v1
164
+ kind: Ingress
165
+ metadata:
166
+ name: ${context.appCode}-custom
167
+ namespace: ${context.namespace}
168
+ annotations: { cert-manager.io/cluster-issuer: ${infra.CUSTOM_DOMAIN_CLUSTER_ISSUER || "letsencrypt-production-http01"} }
169
+ spec:
170
+ ingressClassName: ${infra.INGRESS_CLASS}
171
+ tls: [{ hosts: [${hosts.join(", ")}], secretName: ${context.appCode}-custom-tls }]
172
+ rules:
173
+ ${hosts.map((host) => ` - host: ${host}
174
+ http:
175
+ paths:
176
+ - path: /
177
+ pathType: Prefix
178
+ backend:
179
+ service: { name: ${context.appCode}, port: { name: http } }`).join("\n")}
180
+ `;
181
+ }
182
+
86
183
  function runtimeManifest({ context, infra, imageRef, profile = "small" }) {
87
184
  const resources = RESOURCE_PROFILES[profile] || RESOURCE_PROFILES.small;
185
+ const replicas = replicaCount(context);
186
+ const mode = performanceMode(context);
88
187
  const releaseLabel = kubernetesLabelValue(context.releaseId);
89
188
  const releaseAnnotation = JSON.stringify(String(context.releaseId));
90
189
  return `apiVersion: apps/v1
@@ -93,7 +192,10 @@ metadata:
93
192
  name: ${context.appCode}
94
193
  namespace: ${context.namespace}
95
194
  spec:
96
- replicas: 1
195
+ replicas: ${replicas}
196
+ strategy:
197
+ type: RollingUpdate
198
+ rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }
97
199
  selector:
98
200
  matchLabels: { app.kubernetes.io/name: ${context.appCode} }
99
201
  template:
@@ -106,6 +208,13 @@ spec:
106
208
  oon.dev/release-id: ${releaseAnnotation}
107
209
  spec:
108
210
  serviceAccountName: oon-runtime
211
+ terminationGracePeriodSeconds: ${TERMINATION_GRACE_SECONDS}
212
+ topologySpreadConstraints:
213
+ - maxSkew: 1
214
+ topologyKey: kubernetes.io/hostname
215
+ whenUnsatisfiable: ScheduleAnyway
216
+ labelSelector:
217
+ matchLabels: { app.kubernetes.io/name: ${context.appCode} }
109
218
  imagePullSecrets: [{ name: ${infra.REGISTRY_PULL_SECRET_NAME || "ghcr-oon"} }]
110
219
  containers:
111
220
  - name: central
@@ -113,8 +222,10 @@ spec:
113
222
  imagePullPolicy: IfNotPresent
114
223
  ports: [{ name: http, containerPort: 8080 }]
115
224
  envFrom: [{ secretRef: { name: oon-runtime } }]
116
- ${context.functionalCapabilities?.["core.secrets"]?.enabled ? ` env:
117
- - name: OON_SECRETS_KEY
225
+ env:
226
+ - name: OON_DB_PERFORMANCE_MODE
227
+ value: "${mode}"
228
+ ${context.functionalCapabilities?.["core.secrets"]?.enabled ? ` - name: OON_SECRETS_KEY
118
229
  valueFrom:
119
230
  secretKeyRef: { name: oon-core-secrets, key: OON_SECRETS_KEY, optional: true }
120
231
  ` : ""}
@@ -127,6 +238,20 @@ ${context.functionalCapabilities?.["core.secrets"]?.enabled ? ` env:
127
238
  readinessProbe:
128
239
  httpGet: { path: /api/health/ready, port: http }
129
240
  initialDelaySeconds: 10
241
+ lifecycle:
242
+ # Dá tempo para o ingress tirar o pod dos endpoints antes do SIGTERM.
243
+ preStop:
244
+ exec: { command: ["sleep", "${PRE_STOP_SECONDS}"] }
245
+ ---
246
+ apiVersion: policy/v1
247
+ kind: PodDisruptionBudget
248
+ metadata: { name: ${context.appCode}, namespace: ${context.namespace} }
249
+ spec:
250
+ # Drains e a autoescala de nós tiram no máximo uma réplica por vez; com uma
251
+ # réplica só, o PDB não bloqueia a manutenção do nó.
252
+ maxUnavailable: 1
253
+ selector:
254
+ matchLabels: { app.kubernetes.io/name: ${context.appCode} }
130
255
  ---
131
256
  apiVersion: v1
132
257
  kind: Service
@@ -152,7 +277,7 @@ spec:
152
277
  pathType: Prefix
153
278
  backend:
154
279
  service: { name: ${context.appCode}, port: { name: http } }
155
- `;
280
+ ${customDomainIngress({ context, infra })}`;
156
281
  }
157
282
 
158
283
  function copyPullSecret(namespace, infra) {
@@ -264,6 +389,8 @@ function deployRuntime({ context, infra, imageDigest, resourceProfile = "small"
264
389
  copyPullSecret(context.namespace, infra);
265
390
  const imageRef = `${context.imageRepository}@${imageDigest}`;
266
391
  applyYaml(runtimeManifest({ context, infra, imageRef, profile: resourceProfile }));
392
+ // F4.6: sem domínios ativos, o Ingress dos domínios personalizados sai.
393
+ if (!customDomains(context).length) kubectl(["-n", context.namespace, "delete", "ingress", `${context.appCode}-custom`, "--ignore-not-found"]);
267
394
  kubectl(["-n", context.namespace, "rollout", "status", `deployment/${context.appCode}`, "--timeout=10m"]);
268
395
  return { infraVersion: infra.INFRA_SCHEMA_VERSION, imageRef };
269
396
  }
@@ -306,4 +433,4 @@ async function activateMember(context) {
306
433
  return data;
307
434
  }
308
435
 
309
- module.exports = { supportsSecretsV1: true, RESOURCE_PROFILES, randomSecret, dnsLabel, kubernetesLabelValue, provisionMongo, validateMongoUri, baselineManifest, runtimeManifest, provisionRuntime, deployRuntime, waitHttp, activateMember };
436
+ module.exports = { supportsSecretsV1: true, RESOURCE_PROFILES, MAX_REPLICAS, replicaCount, PERFORMANCE_MODES, performanceMode, MAX_CUSTOM_DOMAINS, customDomains, namespaceQuota, randomSecret, dnsLabel, kubernetesLabelValue, provisionMongo, validateMongoUri, baselineManifest, runtimeManifest, provisionRuntime, deployRuntime, waitHttp, activateMember };
@@ -2,9 +2,19 @@
2
2
 
3
3
  // PID 1 owns both process groups. An unexpected exit (including status 0)
4
4
  // terminates the container; a live proxy cannot hide a dead backend.
5
+ // On SIGTERM each child gets its own stop signal (nginx: SIGQUIT = graceful)
6
+ // and `graceMs` to drain in-flight requests before SIGKILL. The Deployment's
7
+ // terminationGracePeriodSeconds must exceed preStop + graceMs.
5
8
  const { spawn } = require("node:child_process");
6
9
 
7
- function supervise({ backend, proxy, graceMs = 5000 } = {}) {
10
+ const DEFAULT_GRACE_MS = 25000;
11
+
12
+ function graceFromEnv(env = process.env) {
13
+ const value = Number(env.OON_SHUTDOWN_GRACE_MS);
14
+ return Number.isSafeInteger(value) && value > 0 ? value : DEFAULT_GRACE_MS;
15
+ }
16
+
17
+ function supervise({ backend, proxy, graceMs = graceFromEnv() } = {}) {
8
18
  const children = [];
9
19
  let stopping = false;
10
20
  let exitCode = 1;
@@ -25,7 +35,7 @@ function supervise({ backend, proxy, graceMs = 5000 } = {}) {
25
35
  if (stopping) return;
26
36
  stopping = true;
27
37
  exitCode = code;
28
- for (const child of children) signalGroup(child, "SIGTERM");
38
+ for (const child of children) signalGroup(child, child.stopSignal);
29
39
  timer = setTimeout(() => {
30
40
  for (const child of children) signalGroup(child, "SIGKILL");
31
41
  }, graceMs);
@@ -36,6 +46,7 @@ function supervise({ backend, proxy, graceMs = 5000 } = {}) {
36
46
  cwd: spec.cwd, env: { ...process.env, PORT: "4000" },
37
47
  stdio: "inherit", detached: true,
38
48
  });
49
+ child.stopSignal = spec.stopSignal || "SIGTERM";
39
50
  children.push(child);
40
51
  child.once("error", () => { child.failed = true; stop(1); finish(); });
41
52
  child.once("exit", () => { stop(1); finish(); });
@@ -46,7 +57,7 @@ function supervise({ backend, proxy, graceMs = 5000 } = {}) {
46
57
 
47
58
  if (require.main === module) supervise({
48
59
  backend: { command: process.execPath, args: ["/oon-delivery/launchRuntime.js"], cwd: "/app/backend" },
49
- proxy: { command: "nginx", args: ["-c", "/etc/nginx/oon.conf", "-g", "daemon off;"] },
60
+ proxy: { command: "nginx", args: ["-c", "/etc/nginx/oon.conf", "-g", "daemon off;"], stopSignal: "SIGQUIT" },
50
61
  });
51
62
 
52
- module.exports = { supervise };
63
+ module.exports = { supervise, graceFromEnv, DEFAULT_GRACE_MS };
package/src/index.js CHANGED
@@ -83,5 +83,9 @@ module.exports = {
83
83
  PdfRenderingError: capabilities.PdfRenderingError,
84
84
  TransactionalEmailError: capabilities.TransactionalEmailError,
85
85
  GenericError, registry,
86
+ // F4.8: arquivos compartilhados entre réplicas (GridFS no MongoDB do App).
87
+ files: require("./core/storage/files"),
88
+ // F5.3/F5.5: consumo para os limites de uso do plano do App.
89
+ usage: require("./core/usage/reporter"),
86
90
  };
87
91
 
@@ -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 };