@oondemand/oon-core-back 0.3.38 → 0.3.40

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oondemand/oon-core-back",
3
- "version": "0.3.38",
3
+ "version": "0.3.40",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oondemand/oon-platform.git",
@@ -1,13 +1,22 @@
1
1
  const fs = require("node:fs");
2
2
  const path = require("node:path");
3
3
  const ctx = require("../core/context");
4
+ const {
5
+ DOMAIN_MANIFEST_FILENAME,
6
+ loadDomainManifest,
7
+ } = require("../manifest/domainManifest");
4
8
 
5
9
  /**
6
10
  * Descobre e carrega a Central do consumidor:
7
11
  * 1. `central.config.js` na raiz do projeto (mescla na config do Core);
8
- * 2. arquivos de domínio em `src/{models,validations,triggers,routes,
12
+ * 2. `central.domain.json` na raiz (registra models declarativas);
13
+ * 3. arquivos de domínio em `src/{models,validations,triggers,routes,
9
14
  * pipelines,documents,integrations,mappings}` (apenas `require`,
10
15
  * o efeito colateral é registrar via `define*`).
16
+ *
17
+ * O carregamento dos diretórios JavaScript permanece ativo durante a migração.
18
+ * Declarar a mesma model no manifesto e em `src/models` falha explicitamente no
19
+ * registry, impedindo duas fontes de verdade silenciosas.
11
20
  */
12
21
  const DOMAIN_DIRS = [
13
22
  "models",
@@ -28,6 +37,11 @@ function loadCentral(cwd = process.cwd()) {
28
37
  ctx.setConfig(typeof config === "function" ? config() : config);
29
38
  }
30
39
 
40
+ const domainManifestPath = path.join(cwd, DOMAIN_MANIFEST_FILENAME);
41
+ if (fs.existsSync(domainManifestPath)) {
42
+ loadDomainManifest(domainManifestPath);
43
+ }
44
+
31
45
  const srcDir = path.join(cwd, "src");
32
46
  for (const dir of DOMAIN_DIRS) {
33
47
  const full = path.join(srcDir, dir);
package/src/index.js CHANGED
@@ -15,6 +15,7 @@ const fields = require("./core/fields");
15
15
  const registry = require("./core/registry");
16
16
  const GenericError = require("./core/errors/GenericError");
17
17
  const { omie } = require("./modules/omie");
18
+ const domainManifest = require("./manifest/domainManifest");
18
19
 
19
20
  module.exports = {
20
21
  // boot
@@ -33,6 +34,16 @@ module.exports = {
33
34
  defineValidation: define.defineValidation,
34
35
  defineTrigger: define.defineTrigger,
35
36
 
37
+ // manifesto declarativo de domínio
38
+ DOMAIN_MANIFEST_FILENAME: domainManifest.DOMAIN_MANIFEST_FILENAME,
39
+ DOMAIN_MANIFEST_SCHEMA_VERSION: domainManifest.DOMAIN_MANIFEST_SCHEMA_VERSION,
40
+ SUPPORTED_DOMAIN_FIELD_KINDS: domainManifest.SUPPORTED_FIELD_KINDS,
41
+ DomainManifestError: domainManifest.DomainManifestError,
42
+ validateDomainManifest: domainManifest.validateDomainManifest,
43
+ domainManifestToDefinitions: domainManifest.domainManifestToDefinitions,
44
+ registerDomainManifest: domainManifest.registerDomainManifest,
45
+ loadDomainManifest: domainManifest.loadDomainManifest,
46
+
36
47
  // helpers
37
48
  fields,
38
49
  omie,
@@ -0,0 +1,335 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { defineModel } = require("../core/define");
6
+ const fields = require("../core/fields");
7
+
8
+ const DOMAIN_MANIFEST_FILENAME = "central.domain.json";
9
+ const DOMAIN_MANIFEST_SCHEMA_VERSION = 1;
10
+ const SUPPORTED_FIELD_KINDS = Object.freeze([
11
+ "string",
12
+ "number",
13
+ "boolean",
14
+ "date",
15
+ "ref",
16
+ "enum",
17
+ "currency",
18
+ "currencyCode",
19
+ "currencyConverted",
20
+ ]);
21
+ const SUPPORTED_FIELD_KIND_SET = new Set(SUPPORTED_FIELD_KINDS);
22
+
23
+ class DomainManifestError extends Error {
24
+ constructor(message, issues = [], options = {}) {
25
+ super(message, options);
26
+ this.name = "DomainManifestError";
27
+ this.code = "OON_DOMAIN_MANIFEST_INVALID";
28
+ this.statusCode = 422;
29
+ this.issues = issues;
30
+ this.details = { issues };
31
+ }
32
+ }
33
+
34
+ function isPlainObject(value) {
35
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
36
+ }
37
+
38
+ function hasOwn(object, key) {
39
+ return Object.prototype.hasOwnProperty.call(object, key);
40
+ }
41
+
42
+ function addIssue(issues, fieldPath, message) {
43
+ issues.push({ path: fieldPath, message });
44
+ }
45
+
46
+ function isJsonValue(value, seen = new Set()) {
47
+ if (value === null) return true;
48
+ if (["string", "number", "boolean"].includes(typeof value)) {
49
+ return typeof value !== "number" || Number.isFinite(value);
50
+ }
51
+ if (typeof value !== "object" || seen.has(value)) return false;
52
+
53
+ seen.add(value);
54
+ if (Array.isArray(value)) {
55
+ const valid = value.every((item) => isJsonValue(item, seen));
56
+ seen.delete(value);
57
+ return valid;
58
+ }
59
+
60
+ const valid = Object.values(value).every((item) => isJsonValue(item, seen));
61
+ seen.delete(value);
62
+ return valid;
63
+ }
64
+
65
+ function validateBooleanOption(field, optionName, fieldPath, issues) {
66
+ if (hasOwn(field, optionName) && typeof field[optionName] !== "boolean") {
67
+ addIssue(issues, `${fieldPath}.${optionName}`, "deve ser booleano.");
68
+ }
69
+ }
70
+
71
+ function validateNumberOption(field, optionName, fieldPath, issues) {
72
+ if (hasOwn(field, optionName)
73
+ && (typeof field[optionName] !== "number" || !Number.isFinite(field[optionName]))) {
74
+ addIssue(issues, `${fieldPath}.${optionName}`, "deve ser um número finito.");
75
+ }
76
+ }
77
+
78
+ function validateField(fieldName, field, fieldPath, issues) {
79
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(fieldName)) {
80
+ addIssue(issues, fieldPath, "o nome do campo deve ser um identificador JavaScript válido.");
81
+ }
82
+ if (!isPlainObject(field)) {
83
+ addIssue(issues, fieldPath, "deve ser um objeto.");
84
+ return;
85
+ }
86
+ if (!SUPPORTED_FIELD_KIND_SET.has(field.kind)) {
87
+ addIssue(
88
+ issues,
89
+ `${fieldPath}.kind`,
90
+ `tipo não suportado. Use: ${SUPPORTED_FIELD_KINDS.join(", ")}.`,
91
+ );
92
+ }
93
+ if (hasOwn(field, "label") && typeof field.label !== "string") {
94
+ addIssue(issues, `${fieldPath}.label`, "deve ser texto.");
95
+ }
96
+ if (hasOwn(field, "description") && typeof field.description !== "string") {
97
+ addIssue(issues, `${fieldPath}.description`, "deve ser texto.");
98
+ }
99
+
100
+ for (const optionName of ["required", "readonly", "searchable", "unique", "index"]) {
101
+ validateBooleanOption(field, optionName, fieldPath, issues);
102
+ }
103
+ for (const optionName of ["min", "max", "minLength", "maxLength"]) {
104
+ validateNumberOption(field, optionName, fieldPath, issues);
105
+ }
106
+ if (typeof field.min === "number" && typeof field.max === "number" && field.min > field.max) {
107
+ addIssue(issues, fieldPath, "min não pode ser maior que max.");
108
+ }
109
+ if (typeof field.minLength === "number"
110
+ && typeof field.maxLength === "number"
111
+ && field.minLength > field.maxLength) {
112
+ addIssue(issues, fieldPath, "minLength não pode ser maior que maxLength.");
113
+ }
114
+ if (hasOwn(field, "default") && !isJsonValue(field.default)) {
115
+ addIssue(issues, `${fieldPath}.default`, "deve conter somente um valor JSON.");
116
+ }
117
+
118
+ if (field.kind === "ref" && (typeof field.ref !== "string" || !field.ref.trim())) {
119
+ addIssue(issues, `${fieldPath}.ref`, "é obrigatório para campos ref.");
120
+ }
121
+ if (field.kind === "enum") {
122
+ if (!Array.isArray(field.values) || field.values.length === 0) {
123
+ addIssue(issues, `${fieldPath}.values`, "deve ser uma lista não vazia para campos enum.");
124
+ } else {
125
+ const invalid = field.values.some((value) => typeof value !== "string" || !value.length);
126
+ if (invalid) addIssue(issues, `${fieldPath}.values`, "deve conter somente textos não vazios.");
127
+ if (new Set(field.values).size !== field.values.length) {
128
+ addIssue(issues, `${fieldPath}.values`, "não pode conter valores duplicados.");
129
+ }
130
+ }
131
+ }
132
+ if (field.kind === "currencyConverted"
133
+ && hasOwn(field, "base")
134
+ && (typeof field.base !== "string" || !/^[A-Za-z]{3}$/.test(field.base))) {
135
+ addIssue(issues, `${fieldPath}.base`, "deve ser um código de moeda com três letras.");
136
+ }
137
+ }
138
+
139
+ function validateModel(model, index, issues, modelNames) {
140
+ const modelPath = `models[${index}]`;
141
+ if (!isPlainObject(model)) {
142
+ addIssue(issues, modelPath, "deve ser um objeto.");
143
+ return;
144
+ }
145
+ if (typeof model.name !== "string" || !/^[A-Z][A-Za-z0-9]*$/.test(model.name)) {
146
+ addIssue(issues, `${modelPath}.name`, "deve começar com letra maiúscula e conter apenas letras e números.");
147
+ } else if (modelNames.has(model.name)) {
148
+ addIssue(issues, `${modelPath}.name`, `model duplicada: ${model.name}.`);
149
+ } else {
150
+ modelNames.add(model.name);
151
+ }
152
+ if (hasOwn(model, "singular") && (typeof model.singular !== "string" || !model.singular.trim())) {
153
+ addIssue(issues, `${modelPath}.singular`, "deve ser texto não vazio.");
154
+ }
155
+ if (hasOwn(model, "basePath")
156
+ && (typeof model.basePath !== "string" || !/^\/[a-z0-9][a-z0-9/_-]*$/i.test(model.basePath))) {
157
+ addIssue(issues, `${modelPath}.basePath`, "deve ser um caminho iniciado por /.");
158
+ }
159
+ if (hasOwn(model, "crud") && !isPlainObject(model.crud)) {
160
+ addIssue(issues, `${modelPath}.crud`, "deve ser um objeto.");
161
+ }
162
+ if (hasOwn(model, "options") && !isPlainObject(model.options)) {
163
+ addIssue(issues, `${modelPath}.options`, "deve ser um objeto.");
164
+ }
165
+ if (!isPlainObject(model.fields) || Object.keys(model.fields).length === 0) {
166
+ addIssue(issues, `${modelPath}.fields`, "deve ser um objeto com pelo menos um campo.");
167
+ return;
168
+ }
169
+
170
+ for (const [fieldName, field] of Object.entries(model.fields)) {
171
+ validateField(fieldName, field, `${modelPath}.fields.${fieldName}`, issues);
172
+ }
173
+ }
174
+
175
+ function validateDomainManifest(input) {
176
+ const issues = [];
177
+ if (!isPlainObject(input)) {
178
+ throw new DomainManifestError("Manifesto de domínio inválido.", [
179
+ { path: "$", message: "o documento deve ser um objeto JSON." },
180
+ ]);
181
+ }
182
+ if (input.schemaVersion !== DOMAIN_MANIFEST_SCHEMA_VERSION) {
183
+ addIssue(
184
+ issues,
185
+ "schemaVersion",
186
+ `versão não suportada. Use ${DOMAIN_MANIFEST_SCHEMA_VERSION}.`,
187
+ );
188
+ }
189
+ if (typeof input.name !== "string" || !input.name.trim()) {
190
+ addIssue(issues, "name", "é obrigatório e deve ser texto não vazio.");
191
+ }
192
+ if (hasOwn(input, "slug")
193
+ && (typeof input.slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.slug))) {
194
+ addIssue(issues, "slug", "deve usar letras minúsculas, números e hífens.");
195
+ }
196
+ if (!Array.isArray(input.models) || input.models.length === 0) {
197
+ addIssue(issues, "models", "deve ser uma lista com pelo menos uma model.");
198
+ } else {
199
+ const modelNames = new Set();
200
+ input.models.forEach((model, index) => validateModel(model, index, issues, modelNames));
201
+ }
202
+
203
+ if (issues.length) {
204
+ throw new DomainManifestError(
205
+ `Manifesto de domínio inválido: ${issues.length} problema(s) encontrado(s).`,
206
+ issues,
207
+ );
208
+ }
209
+
210
+ return JSON.parse(JSON.stringify(input));
211
+ }
212
+
213
+ function baseFieldOptions(field) {
214
+ const options = {
215
+ required: field.required,
216
+ label: field.label,
217
+ searchable: field.searchable,
218
+ };
219
+ if (hasOwn(field, "default")) options.default = field.default;
220
+ return options;
221
+ }
222
+
223
+ function createFieldDescriptor(field) {
224
+ const options = baseFieldOptions(field);
225
+ let descriptor;
226
+
227
+ switch (field.kind) {
228
+ case "string":
229
+ descriptor = fields.string(options);
230
+ break;
231
+ case "number":
232
+ descriptor = fields.number(options);
233
+ break;
234
+ case "boolean":
235
+ descriptor = fields.boolean(options);
236
+ break;
237
+ case "date":
238
+ descriptor = fields.date(options);
239
+ break;
240
+ case "ref":
241
+ descriptor = fields.ref(field.ref, options);
242
+ break;
243
+ case "enum":
244
+ descriptor = fields.enum(field.values, options);
245
+ break;
246
+ case "currency":
247
+ descriptor = fields.currency(options);
248
+ break;
249
+ case "currencyCode":
250
+ descriptor = fields.currencyCode(options);
251
+ break;
252
+ case "currencyConverted":
253
+ descriptor = fields.currencyConverted({ ...options, base: field.base });
254
+ break;
255
+ default:
256
+ throw new DomainManifestError("Tipo de campo não suportado após validação.", [
257
+ { path: "kind", message: String(field.kind) },
258
+ ]);
259
+ }
260
+
261
+ if (field.readonly) descriptor.immutable = true;
262
+ if (field.unique) descriptor.unique = true;
263
+ if (field.index) descriptor.index = true;
264
+ if (typeof field.min === "number") descriptor.min = field.min;
265
+ if (typeof field.max === "number") descriptor.max = field.max;
266
+ if (typeof field.minLength === "number") descriptor.minlength = field.minLength;
267
+ if (typeof field.maxLength === "number") descriptor.maxlength = field.maxLength;
268
+
269
+ descriptor.__meta = {
270
+ ...descriptor.__meta,
271
+ description: field.description,
272
+ readonly: Boolean(field.readonly),
273
+ unique: Boolean(field.unique),
274
+ index: Boolean(field.index),
275
+ };
276
+ return descriptor;
277
+ }
278
+
279
+ function domainManifestToDefinitions(input) {
280
+ const manifest = validateDomainManifest(input);
281
+ return manifest.models.map((model) => ({
282
+ name: model.name,
283
+ singular: model.singular,
284
+ basePath: model.basePath,
285
+ crud: model.crud,
286
+ options: model.options,
287
+ schema: Object.fromEntries(
288
+ Object.entries(model.fields).map(([fieldName, field]) => [
289
+ fieldName,
290
+ createFieldDescriptor(field),
291
+ ]),
292
+ ),
293
+ }));
294
+ }
295
+
296
+ function registerDomainManifest(input) {
297
+ const definitions = domainManifestToDefinitions(input);
298
+ return definitions.map((definition) => defineModel(definition));
299
+ }
300
+
301
+ function loadDomainManifest(filePath, options = {}) {
302
+ const resolvedPath = path.resolve(filePath);
303
+ let parsed;
304
+ try {
305
+ parsed = JSON.parse(fs.readFileSync(resolvedPath, "utf8"));
306
+ } catch (error) {
307
+ if (error instanceof SyntaxError) {
308
+ throw new DomainManifestError(
309
+ `JSON inválido em ${resolvedPath}.`,
310
+ [{ path: "$", message: error.message }],
311
+ { cause: error },
312
+ );
313
+ }
314
+ throw error;
315
+ }
316
+
317
+ const manifest = validateDomainManifest(parsed);
318
+ const definitions = domainManifestToDefinitions(manifest);
319
+ const entries = options.register === false
320
+ ? []
321
+ : definitions.map((definition) => defineModel(definition));
322
+
323
+ return { filePath: resolvedPath, manifest, definitions, entries };
324
+ }
325
+
326
+ module.exports = {
327
+ DOMAIN_MANIFEST_FILENAME,
328
+ DOMAIN_MANIFEST_SCHEMA_VERSION,
329
+ SUPPORTED_FIELD_KINDS,
330
+ DomainManifestError,
331
+ validateDomainManifest,
332
+ domainManifestToDefinitions,
333
+ registerDomainManifest,
334
+ loadDomainManifest,
335
+ };