@oondemand/oon-core-back 0.3.46 → 0.3.48

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,578 @@
1
+ "use strict";
2
+
3
+ const mongoose = require("mongoose");
4
+ const registry = require("../core/registry");
5
+ const {
6
+ DomainRuleError,
7
+ applyDomainMutation,
8
+ evaluateDomainExpression,
9
+ roundValue,
10
+ valuesEquivalent,
11
+ } = require("./domainRules");
12
+
13
+ const MAX_AUTOMATIC_TRANSITIONS = 8;
14
+ const MAX_TRANSACTION_ATTEMPTS = 3;
15
+ let asynchronousQueue = Promise.resolve();
16
+
17
+ function toPlainRecord(record) {
18
+ if (!record || typeof record !== "object") return {};
19
+ if (typeof record.toObject === "function") {
20
+ return record.toObject({ depopulate: true, getters: false, virtuals: false });
21
+ }
22
+ return { ...record };
23
+ }
24
+
25
+ function idKey(value) {
26
+ if (value === null || value === undefined) return null;
27
+ if (typeof value === "object" && value._id) return String(value._id);
28
+ return String(value);
29
+ }
30
+
31
+ function hasOwn(object, key) {
32
+ return Object.prototype.hasOwnProperty.call(object, key);
33
+ }
34
+
35
+ function queryWithSession(query, session) {
36
+ return session && query && typeof query.session === "function" ? query.session(session) : query;
37
+ }
38
+
39
+ function compileMongoMatch(match = {}) {
40
+ const query = {};
41
+ for (const [field, condition] of Object.entries(match || {})) {
42
+ if (condition && typeof condition === "object" && !Array.isArray(condition)) {
43
+ if (hasOwn(condition, "eq")) query[field] = { $eq: condition.eq };
44
+ else if (hasOwn(condition, "neq")) query[field] = { $ne: condition.neq };
45
+ else if (hasOwn(condition, "in")) query[field] = { $in: condition.in };
46
+ else if (hasOwn(condition, "nin")) query[field] = { $nin: condition.nin };
47
+ else query[field] = condition;
48
+ } else {
49
+ query[field] = condition;
50
+ }
51
+ }
52
+ return query;
53
+ }
54
+
55
+ function recordMatches(record, match = {}) {
56
+ for (const [field, condition] of Object.entries(match || {})) {
57
+ const value = record?.[field];
58
+ if (condition && typeof condition === "object" && !Array.isArray(condition)) {
59
+ if (hasOwn(condition, "eq") && !valuesEquivalent(value, condition.eq)) return false;
60
+ if (hasOwn(condition, "neq") && valuesEquivalent(value, condition.neq)) return false;
61
+ if (hasOwn(condition, "in")
62
+ && !condition.in.some((candidate) => valuesEquivalent(value, candidate))) return false;
63
+ if (hasOwn(condition, "nin")
64
+ && condition.nin.some((candidate) => valuesEquivalent(value, candidate))) return false;
65
+ } else if (!valuesEquivalent(value, condition)) {
66
+ return false;
67
+ }
68
+ }
69
+ return true;
70
+ }
71
+
72
+ function evaluateCondition(expression, record, context = {}) {
73
+ if (!expression) return true;
74
+ return Boolean(evaluateDomainExpression(expression, record, context));
75
+ }
76
+
77
+ function workflowError(message, field, rule, details = {}) {
78
+ throw new DomainRuleError(message, {
79
+ code: "OON_WORKFLOW_TRANSITION_INVALID",
80
+ statusCode: 409,
81
+ field,
82
+ rule,
83
+ details,
84
+ });
85
+ }
86
+
87
+ function applyWorkflowRules({ processRules, current = {}, changes = {}, op = "update", system = false }) {
88
+ const workflow = processRules?.workflow;
89
+ if (!workflow) return { changes: {}, stageChanged: false };
90
+
91
+ const stageField = workflow.stageField;
92
+ const previousStage = current?.[stageField];
93
+ const requestedStage = changes?.[stageField];
94
+ const stageChanged = hasOwn(changes, stageField)
95
+ && !valuesEquivalent(previousStage, requestedStage);
96
+ const systemChanges = {};
97
+
98
+ if (!system) {
99
+ const lockedFields = workflow.lockedFieldsByStage?.[previousStage] || [];
100
+ const changedLockedField = lockedFields.find(
101
+ (field) => hasOwn(changes, field) && !valuesEquivalent(current?.[field], changes[field]),
102
+ );
103
+ if (changedLockedField) {
104
+ workflowError(
105
+ workflow.lockedMessage || "Os dados de negócio estão bloqueados nesta etapa.",
106
+ changedLockedField,
107
+ "lockedFieldsByStage",
108
+ { stage: previousStage },
109
+ );
110
+ }
111
+
112
+ if (op === "create" && workflow.initialStages?.length) {
113
+ const initialStage = requestedStage ?? workflow.defaultStage;
114
+ if (!workflow.initialStages.includes(initialStage)) {
115
+ workflowError(
116
+ workflow.initialStageMessage || "O registro deve iniciar em uma etapa permitida.",
117
+ stageField,
118
+ "initialStages",
119
+ { stage: initialStage },
120
+ );
121
+ }
122
+ }
123
+
124
+ if (op !== "create" && stageChanged) {
125
+ const intended = { ...current, ...changes };
126
+ const transition = (workflow.transitions || []).find(
127
+ (item) => valuesEquivalent(item.from, previousStage)
128
+ && valuesEquivalent(item.to, requestedStage)
129
+ && evaluateCondition(item.when, intended, { field: stageField, rule: "transition" }),
130
+ );
131
+ if (!transition) {
132
+ workflowError(
133
+ `Transição inválida de ${String(previousStage)} para ${String(requestedStage)}.`,
134
+ stageField,
135
+ "transitions",
136
+ { from: previousStage, to: requestedStage },
137
+ );
138
+ }
139
+ }
140
+ }
141
+
142
+ if (stageChanged || (op === "create" && requestedStage !== undefined)) {
143
+ for (const assignment of workflow.onEnter || []) {
144
+ if (valuesEquivalent(assignment.stage, requestedStage)) Object.assign(systemChanges, assignment.set || {});
145
+ }
146
+ }
147
+
148
+ return { changes: systemChanges, stageChanged };
149
+ }
150
+
151
+ function applyAutomaticTransitions(processRules, record) {
152
+ const workflow = processRules?.workflow;
153
+ if (!workflow?.automaticTransitions?.length) return {};
154
+ const stageField = workflow.stageField;
155
+ const next = { ...record };
156
+ const systemChanges = {};
157
+
158
+ for (let attempt = 0; attempt < MAX_AUTOMATIC_TRANSITIONS; attempt += 1) {
159
+ const transition = workflow.automaticTransitions.find((item) => (
160
+ !valuesEquivalent(next[stageField], item.to)
161
+ && evaluateCondition(item.when, next, { field: stageField, rule: "automaticTransition" })
162
+ ));
163
+ if (!transition) break;
164
+
165
+ next[stageField] = transition.to;
166
+ systemChanges[stageField] = transition.to;
167
+ Object.assign(next, transition.set || {});
168
+ Object.assign(systemChanges, transition.set || {});
169
+ for (const assignment of workflow.onEnter || []) {
170
+ if (valuesEquivalent(assignment.stage, transition.to)) {
171
+ Object.assign(next, assignment.set || {});
172
+ Object.assign(systemChanges, assignment.set || {});
173
+ }
174
+ }
175
+ }
176
+
177
+ return systemChanges;
178
+ }
179
+
180
+ function bindingDefault(binding) {
181
+ if (hasOwn(binding, "default")) return binding.default;
182
+ return binding.kind === "aggregate" ? 0 : null;
183
+ }
184
+
185
+ async function loadLookupValues(binding, states, session) {
186
+ const sourceEntry = registry.getModel(binding.sourceModel);
187
+ if (!sourceEntry) throw new Error(`Model ${binding.sourceModel} não registrada.`);
188
+ const ids = [...new Set(states.map((state) => idKey(state[binding.localField])).filter(Boolean))];
189
+ if (!ids.length) return new Map();
190
+ let query = sourceEntry.mongooseModel
191
+ .find({ _id: { $in: ids } })
192
+ .select(`_id ${binding.sourceField}`);
193
+ query = queryWithSession(query, session);
194
+ const rows = await query.lean();
195
+ return new Map(rows.map((row) => [idKey(row._id), row[binding.sourceField]]));
196
+ }
197
+
198
+ async function loadAggregateValues(binding, states, session) {
199
+ const sourceEntry = registry.getModel(binding.sourceModel);
200
+ if (!sourceEntry) throw new Error(`Model ${binding.sourceModel} não registrada.`);
201
+ const parentIds = [...new Map(
202
+ states.map((state) => [idKey(state._id), state._id]).filter(([key]) => Boolean(key)),
203
+ ).values()];
204
+ if (!parentIds.length) return new Map();
205
+
206
+ const match = {
207
+ [binding.foreignField]: { $in: parentIds },
208
+ ...compileMongoMatch(binding.match),
209
+ };
210
+ let accumulator;
211
+ switch (binding.operator) {
212
+ case "count": accumulator = { $sum: 1 }; break;
213
+ case "min": accumulator = { $min: `$${binding.sourceField}` }; break;
214
+ case "max": accumulator = { $max: `$${binding.sourceField}` }; break;
215
+ case "sum":
216
+ default: accumulator = { $sum: { $ifNull: [`$${binding.sourceField}`, 0] } }; break;
217
+ }
218
+ let aggregate = sourceEntry.mongooseModel.aggregate([
219
+ { $match: match },
220
+ { $group: { _id: `$${binding.foreignField}`, value: accumulator } },
221
+ ]);
222
+ if (session && typeof aggregate.session === "function") aggregate = aggregate.session(session);
223
+ const rows = await aggregate;
224
+ return new Map(rows.map((row) => [idKey(row._id), row.value]));
225
+ }
226
+
227
+ async function resolveBindingsForRecords(entry, records, options = {}) {
228
+ const bindings = entry.definition?.processRules?.bindings || [];
229
+ const states = records.map((record) => ({ ...record }));
230
+ const derived = states.map(() => ({}));
231
+
232
+ for (const binding of bindings) {
233
+ if (binding.kind === "lookup") {
234
+ const values = await loadLookupValues(binding, states, options.session);
235
+ states.forEach((state, index) => {
236
+ const value = values.has(idKey(state[binding.localField]))
237
+ ? values.get(idKey(state[binding.localField]))
238
+ : bindingDefault(binding);
239
+ state[binding.field] = value;
240
+ derived[index][binding.field] = value;
241
+ });
242
+ continue;
243
+ }
244
+
245
+ if (binding.kind === "aggregate") {
246
+ const values = await loadAggregateValues(binding, states, options.session);
247
+ states.forEach((state, index) => {
248
+ const value = values.has(idKey(state._id))
249
+ ? values.get(idKey(state._id))
250
+ : bindingDefault(binding);
251
+ state[binding.field] = value;
252
+ derived[index][binding.field] = value;
253
+ });
254
+ continue;
255
+ }
256
+
257
+ states.forEach((state, index) => {
258
+ const raw = evaluateDomainExpression(binding.expression, state, {
259
+ field: binding.field,
260
+ rule: "processBinding",
261
+ nullAsZero: binding.nullAsZero !== false,
262
+ });
263
+ const value = typeof raw === "number" && hasOwn(binding, "precision")
264
+ ? roundValue(raw, binding.precision)
265
+ : raw;
266
+ state[binding.field] = value;
267
+ derived[index][binding.field] = value;
268
+ });
269
+ }
270
+
271
+ return { states, derived };
272
+ }
273
+
274
+ async function prepareProcessMutations(entry, currents, requestedChanges, options = {}) {
275
+ const processRules = entry.definition?.processRules || {};
276
+ const domainRules = entry.definition?.domainRules;
277
+ const op = options.op || "update";
278
+ const bases = currents.map((current, index) => {
279
+ const requested = requestedChanges[index] || {};
280
+ const workflow = applyWorkflowRules({
281
+ processRules,
282
+ current,
283
+ changes: requested,
284
+ op,
285
+ system: Boolean(options.system),
286
+ });
287
+ const prepared = applyDomainMutation({
288
+ rules: domainRules,
289
+ current,
290
+ changes: requested,
291
+ systemChanges: workflow.changes,
292
+ op: op === "create" ? "create" : "update",
293
+ skipValidations: true,
294
+ });
295
+ return { requested, workflow, prepared };
296
+ });
297
+
298
+ const resolved = await resolveBindingsForRecords(
299
+ entry,
300
+ bases.map((base) => base.prepared.consolidated),
301
+ options,
302
+ );
303
+
304
+ return bases.map((base, index) => {
305
+ const automaticChanges = applyAutomaticTransitions(processRules, resolved.states[index]);
306
+ const systemChanges = {
307
+ ...base.workflow.changes,
308
+ ...resolved.derived[index],
309
+ ...automaticChanges,
310
+ };
311
+ return applyDomainMutation({
312
+ rules: domainRules,
313
+ current: currents[index],
314
+ changes: base.requested,
315
+ systemChanges,
316
+ op: op === "create" ? "create" : "update",
317
+ });
318
+ });
319
+ }
320
+
321
+ async function prepareProcessMutation(entry, current, changes, options = {}) {
322
+ const [prepared] = await prepareProcessMutations(
323
+ entry,
324
+ [toPlainRecord(current)],
325
+ [changes || {}],
326
+ options,
327
+ );
328
+ return prepared;
329
+ }
330
+
331
+ function invariantError(rule, total, limit) {
332
+ const message = String(rule.message)
333
+ .replaceAll("{total}", Number(total).toFixed(2))
334
+ .replaceAll("{limit}", Number(limit).toFixed(2));
335
+ throw new DomainRuleError(message, {
336
+ code: rule.code || "OON_ATOMIC_INVARIANT_FAILED",
337
+ statusCode: 409,
338
+ field: rule.sourceField,
339
+ rule: rule.name,
340
+ details: { total, limit },
341
+ });
342
+ }
343
+
344
+ async function assertAtomicInvariants(entry, candidate, current, session) {
345
+ for (const rule of entry.definition?.processRules?.atomicInvariants || []) {
346
+ if (rule.kind !== "relatedSumLteParentField") continue;
347
+ const parentId = candidate?.[rule.parentLocalField];
348
+ if (!parentId) continue;
349
+ const parentEntry = registry.getModel(rule.parentModel);
350
+ if (!parentEntry) throw new Error(`Model pai ${rule.parentModel} não registrada.`);
351
+
352
+ let parentQuery = parentEntry.mongooseModel.findOneAndUpdate(
353
+ { _id: parentId },
354
+ { $inc: { __oonInvariantVersion: 1 } },
355
+ { new: true, session, projection: { [rule.parentField]: 1 } },
356
+ );
357
+ parentQuery = queryWithSession(parentQuery, session);
358
+ const parent = await parentQuery.lean();
359
+ if (!parent) {
360
+ throw new DomainRuleError("Registro pai da invariável não encontrado.", {
361
+ code: "OON_ATOMIC_PARENT_NOT_FOUND",
362
+ statusCode: 422,
363
+ field: rule.parentLocalField,
364
+ rule: rule.name,
365
+ });
366
+ }
367
+
368
+ const match = {
369
+ [rule.parentLocalField]: parentId,
370
+ ...compileMongoMatch(rule.match),
371
+ };
372
+ if (current?._id) match._id = { $ne: current._id };
373
+ let aggregate = entry.mongooseModel.aggregate([
374
+ { $match: match },
375
+ { $group: { _id: null, total: { $sum: { $ifNull: [`$${rule.sourceField}`, 0] } } } },
376
+ ]);
377
+ if (session && typeof aggregate.session === "function") aggregate = aggregate.session(session);
378
+ const rows = await aggregate;
379
+ const existing = Number(rows[0]?.total || 0);
380
+ const candidateValue = recordMatches(candidate, rule.match)
381
+ ? Number(candidate?.[rule.sourceField] || 0)
382
+ : 0;
383
+ const total = existing + candidateValue;
384
+ const limit = Number(parent?.[rule.parentField] || 0);
385
+ if (total > limit + Number(rule.tolerance || 0)) invariantError(rule, total, limit);
386
+ }
387
+ }
388
+
389
+ async function assertDeleteAllowed(entry, id, options = {}) {
390
+ for (const rule of entry.definition?.processRules?.deleteProtection || []) {
391
+ const sourceEntry = registry.getModel(rule.sourceModel);
392
+ if (!sourceEntry) throw new Error(`Model ${rule.sourceModel} não registrada.`);
393
+ let query = sourceEntry.mongooseModel.exists({
394
+ [rule.foreignField]: id,
395
+ ...compileMongoMatch(rule.match),
396
+ });
397
+ query = queryWithSession(query, options.session);
398
+ if (await query) {
399
+ throw new DomainRuleError(rule.message, {
400
+ code: rule.code || "OON_DELETE_PROTECTED",
401
+ statusCode: 409,
402
+ field: null,
403
+ rule: "deleteProtection",
404
+ details: { sourceModel: rule.sourceModel, foreignField: rule.foreignField },
405
+ });
406
+ }
407
+ }
408
+ }
409
+
410
+ function isTransientTransactionError(error) {
411
+ return Boolean(
412
+ error?.hasErrorLabel?.("TransientTransactionError")
413
+ || error?.hasErrorLabel?.("UnknownTransactionCommitResult")
414
+ || [112, 244, 251].includes(error?.code),
415
+ );
416
+ }
417
+
418
+ async function withProcessTransaction(entry, work) {
419
+ const requiresTransaction = Boolean(
420
+ entry.definition?.processRules?.atomicInvariants?.length,
421
+ );
422
+ if (!requiresTransaction) return work(null);
423
+
424
+ for (let attempt = 1; attempt <= MAX_TRANSACTION_ATTEMPTS; attempt += 1) {
425
+ const session = await mongoose.startSession();
426
+ try {
427
+ let result;
428
+ await session.withTransaction(async () => {
429
+ result = await work(session);
430
+ }, {
431
+ readConcern: { level: "snapshot" },
432
+ writeConcern: { w: "majority" },
433
+ });
434
+ return result;
435
+ } catch (error) {
436
+ if (attempt === MAX_TRANSACTION_ATTEMPTS || !isTransientTransactionError(error)) throw error;
437
+ } finally {
438
+ await session.endSession();
439
+ }
440
+ }
441
+ throw new Error("Não foi possível concluir a transação de domínio.");
442
+ }
443
+
444
+ function bindingAffectedBySource(binding, sourceName, changedFields) {
445
+ if (binding.sourceModel !== sourceName) return false;
446
+ const watched = binding.watchFields?.length
447
+ ? binding.watchFields
448
+ : [binding.sourceField].filter(Boolean);
449
+ return !watched.length || watched.some((field) => changedFields.includes(field));
450
+ }
451
+
452
+ function collectDependentTargets(sourceEntry, before, after, changedFields) {
453
+ const targets = new Map();
454
+ for (const targetEntry of registry.listModels()) {
455
+ for (const binding of targetEntry.definition?.processRules?.bindings || []) {
456
+ if (!bindingAffectedBySource(binding, sourceEntry.name, changedFields)) continue;
457
+ const ids = [];
458
+ if (binding.kind === "lookup") {
459
+ const sourceId = after?._id || before?._id;
460
+ if (sourceId) ids.push({ field: binding.localField, value: sourceId });
461
+ } else if (binding.kind === "aggregate") {
462
+ for (const record of [before, after]) {
463
+ if (record?.[binding.foreignField]) {
464
+ ids.push({ field: "_id", value: record[binding.foreignField] });
465
+ }
466
+ }
467
+ }
468
+ if (!ids.length) continue;
469
+ const current = targets.get(targetEntry.name) || {
470
+ entry: targetEntry,
471
+ selectors: [],
472
+ mode: "immediate",
473
+ };
474
+ current.selectors.push(...ids);
475
+ if (binding.recalculate === "async") current.mode = "async";
476
+ targets.set(targetEntry.name, current);
477
+ }
478
+ }
479
+ return [...targets.values()];
480
+ }
481
+
482
+ async function recalculateTarget(entry, selectors, options = {}) {
483
+ const clauses = selectors.map(({ field, value }) => ({ [field]: value }));
484
+ if (!clauses.length) return { matched: 0, modified: 0 };
485
+ let query = entry.mongooseModel.find(clauses.length === 1 ? clauses[0] : { $or: clauses });
486
+ query = queryWithSession(query, options.session);
487
+ const documents = await query.lean();
488
+ if (!documents.length) return { matched: 0, modified: 0 };
489
+
490
+ const prepared = await prepareProcessMutations(
491
+ entry,
492
+ documents,
493
+ documents.map(() => ({})),
494
+ { ...options, op: "system", system: true },
495
+ );
496
+ const operations = [];
497
+ prepared.forEach((result, index) => {
498
+ const changed = Object.fromEntries(
499
+ result.changedFields
500
+ .filter((field) => hasOwn(result.changes, field))
501
+ .map((field) => [field, result.changes[field]]),
502
+ );
503
+ if (Object.keys(changed).length) {
504
+ operations.push({
505
+ updateOne: {
506
+ filter: { _id: documents[index]._id },
507
+ update: { $set: changed },
508
+ },
509
+ });
510
+ }
511
+ });
512
+ if (!operations.length) return { matched: documents.length, modified: 0 };
513
+ const result = await entry.mongooseModel.bulkWrite(operations, {
514
+ ordered: false,
515
+ session: options.session || undefined,
516
+ });
517
+ return { matched: documents.length, modified: result.modifiedCount || 0 };
518
+ }
519
+
520
+ function enqueueAsynchronousRecalculation(target) {
521
+ asynchronousQueue = asynchronousQueue
522
+ .then(() => new Promise((resolve) => setImmediate(resolve)))
523
+ .then(() => recalculateTarget(target.entry, target.selectors, { session: null }))
524
+ .catch((error) => {
525
+ // A fila não pode morrer silenciosamente nem bloquear novas tarefas.
526
+ console.error("OON_PROCESS_ASYNC_RECALCULATION_FAILED", {
527
+ model: target.entry.name,
528
+ message: error.message,
529
+ });
530
+ });
531
+ return asynchronousQueue;
532
+ }
533
+
534
+ async function recalculateDependents(entry, before, after, options = {}) {
535
+ const previous = toPlainRecord(before);
536
+ const next = toPlainRecord(after);
537
+ const changedFields = options.changedFields || [
538
+ ...new Set([...Object.keys(previous), ...Object.keys(next)]),
539
+ ].filter((field) => !valuesEquivalent(previous[field], next[field]));
540
+ if (!changedFields.length) return [];
541
+
542
+ const targets = collectDependentTargets(entry, previous, next, changedFields);
543
+ const results = [];
544
+ for (const target of targets) {
545
+ if (target.mode === "async") {
546
+ enqueueAsynchronousRecalculation(target);
547
+ results.push({ model: target.entry.name, mode: "async" });
548
+ } else {
549
+ results.push({
550
+ model: target.entry.name,
551
+ mode: "immediate",
552
+ ...(await recalculateTarget(target.entry, target.selectors, options)),
553
+ });
554
+ }
555
+ }
556
+ return results;
557
+ }
558
+
559
+ async function drainProcessJobs() {
560
+ await asynchronousQueue;
561
+ }
562
+
563
+ module.exports = {
564
+ compileMongoMatch,
565
+ recordMatches,
566
+ applyWorkflowRules,
567
+ applyAutomaticTransitions,
568
+ resolveBindingsForRecords,
569
+ prepareProcessMutation,
570
+ prepareProcessMutations,
571
+ assertAtomicInvariants,
572
+ assertDeleteAllowed,
573
+ withProcessTransaction,
574
+ recalculateDependents,
575
+ recalculateTarget,
576
+ drainProcessJobs,
577
+ toPlainRecord,
578
+ };
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+
3
+ const registry = require("../core/registry");
4
+ const { DomainRuleError, valuesEquivalent } = require("./domainRules");
5
+
6
+ function idKey(value) {
7
+ if (value === null || value === undefined || value === "") return null;
8
+ if (typeof value === "object") {
9
+ if (value._id) return idKey(value._id);
10
+ if (typeof value.toHexString === "function") return String(value.toHexString());
11
+ return null;
12
+ }
13
+ return String(value);
14
+ }
15
+
16
+ function referenceValuesEquivalent(left, right) {
17
+ if (valuesEquivalent(left, right)) return true;
18
+ const leftKey = idKey(left);
19
+ const rightKey = idKey(right);
20
+ return leftKey !== null && rightKey !== null && leftKey === rightKey;
21
+ }
22
+
23
+ function queryWithSession(query, session) {
24
+ return session && query && typeof query.session === "function" ? query.session(session) : query;
25
+ }
26
+
27
+ function referenceError(rule, message, details = {}) {
28
+ throw new DomainRuleError(message || rule.message || "Referência inválida.", {
29
+ code: rule.code || "OON_REFERENCE_POLICY_FAILED",
30
+ statusCode: 422,
31
+ field: rule.field,
32
+ rule: "referencePolicy",
33
+ details,
34
+ });
35
+ }
36
+
37
+ function requiredProjection(rules) {
38
+ const fields = new Set(["_id"]);
39
+ for (const rule of rules) {
40
+ if (rule.active?.field) fields.add(rule.active.field);
41
+ for (const constraint of rule.constraints || []) {
42
+ if (constraint.sourceField) fields.add(constraint.sourceField);
43
+ }
44
+ }
45
+ return [...fields].join(" ");
46
+ }
47
+
48
+ function constraintMatches(constraint, source, local) {
49
+ const value = source?.[constraint.sourceField];
50
+ if (constraint.equalsField) {
51
+ return referenceValuesEquivalent(value, local?.[constraint.equalsField]);
52
+ }
53
+ if (Object.prototype.hasOwnProperty.call(constraint, "equals")) {
54
+ return valuesEquivalent(value, constraint.equals);
55
+ }
56
+ if (constraint.in) return constraint.in.some((candidate) => valuesEquivalent(value, candidate));
57
+ if (constraint.nin) return !constraint.nin.some((candidate) => valuesEquivalent(value, candidate));
58
+ if (constraint.empty === true) return value === null || value === undefined || value === "";
59
+ if (constraint.present === true) return value !== null && value !== undefined && value !== "";
60
+ return true;
61
+ }
62
+
63
+ async function assertReferencePolicies(entry, candidate, options = {}) {
64
+ const rules = entry.definition?.processRules?.references || [];
65
+ if (!rules.length) return;
66
+
67
+ const grouped = new Map();
68
+ for (const rule of rules) {
69
+ const id = idKey(candidate?.[rule.field]);
70
+ if (!id) {
71
+ if (rule.required !== false) referenceError(rule, rule.message || "Selecione um registro válido.");
72
+ continue;
73
+ }
74
+ const sourceEntry = registry.getModel(rule.sourceModel);
75
+ if (!sourceEntry) {
76
+ referenceError(rule, `Model de referência ${rule.sourceModel} não registrada.`);
77
+ }
78
+ const group = grouped.get(rule.sourceModel) || { entry: sourceEntry, rules: [], ids: new Map() };
79
+ group.rules.push({ rule, id });
80
+ group.ids.set(id, candidate[rule.field]);
81
+ grouped.set(rule.sourceModel, group);
82
+ }
83
+
84
+ for (const group of grouped.values()) {
85
+ let query = group.entry.mongooseModel
86
+ .find({ _id: { $in: [...group.ids.values()] } })
87
+ .select(requiredProjection(group.rules.map((item) => item.rule)));
88
+ query = queryWithSession(query, options.session);
89
+ const records = await query.lean();
90
+ const byId = new Map(records.map((record) => [idKey(record._id), record]));
91
+
92
+ for (const { rule, id } of group.rules) {
93
+ const source = byId.get(id);
94
+ if (!source) referenceError(rule, rule.message || "Registro relacionado não encontrado.", { id });
95
+ if (rule.active) {
96
+ const activeValue = Object.prototype.hasOwnProperty.call(rule.active, "value")
97
+ ? rule.active.value
98
+ : "Ativo";
99
+ if (!valuesEquivalent(source[rule.active.field], activeValue)) {
100
+ referenceError(rule, rule.active.message || rule.message || "Selecione um registro ativo.", {
101
+ id,
102
+ activeField: rule.active.field,
103
+ });
104
+ }
105
+ }
106
+ for (const constraint of rule.constraints || []) {
107
+ if (!constraintMatches(constraint, source, candidate)) {
108
+ referenceError(rule, constraint.message || rule.message, {
109
+ id,
110
+ sourceField: constraint.sourceField,
111
+ });
112
+ }
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ module.exports = {
119
+ assertReferencePolicies,
120
+ constraintMatches,
121
+ };