@maykonpaulo/maestro-core 0.2.1 → 0.3.0-next.1
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/README.md +94 -90
- package/dist/index.d.ts +1042 -2
- package/dist/index.js +2431 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -70,6 +70,52 @@ var ConsoleLogger = class {
|
|
|
70
70
|
}
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
+
// src/logging/ConsoleStructuredLogger.ts
|
|
74
|
+
var ConsoleStructuredLogger = class {
|
|
75
|
+
constructor(minLevel = "info") {
|
|
76
|
+
this.minLevel = minLevel;
|
|
77
|
+
}
|
|
78
|
+
minLevel;
|
|
79
|
+
debug(entry) {
|
|
80
|
+
this.log("debug", entry);
|
|
81
|
+
}
|
|
82
|
+
info(entry) {
|
|
83
|
+
this.log("info", entry);
|
|
84
|
+
}
|
|
85
|
+
warn(entry) {
|
|
86
|
+
this.log("warn", entry);
|
|
87
|
+
}
|
|
88
|
+
error(entry) {
|
|
89
|
+
this.log("error", entry);
|
|
90
|
+
}
|
|
91
|
+
log(level, entry) {
|
|
92
|
+
if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[this.minLevel]) return;
|
|
93
|
+
const output = {
|
|
94
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
95
|
+
level
|
|
96
|
+
};
|
|
97
|
+
if (entry.correlationId !== void 0) output["correlationId"] = entry.correlationId;
|
|
98
|
+
if (entry.operation !== void 0) output["operation"] = entry.operation;
|
|
99
|
+
if (entry.entity !== void 0) output["entity"] = entry.entity;
|
|
100
|
+
if (entry.entityId !== void 0) output["entityId"] = entry.entityId;
|
|
101
|
+
if (entry.actor !== void 0) output["actor"] = entry.actor;
|
|
102
|
+
if (entry.durationMs !== void 0) output["durationMs"] = entry.durationMs;
|
|
103
|
+
if (entry.status !== void 0) output["status"] = entry.status;
|
|
104
|
+
if (entry.severity !== void 0) output["severity"] = entry.severity;
|
|
105
|
+
if (entry.message !== void 0) output["message"] = entry.message;
|
|
106
|
+
if (entry.metadata !== void 0) output["metadata"] = entry.metadata;
|
|
107
|
+
if (entry.error !== void 0) output["error"] = this.serializeError(entry.error);
|
|
108
|
+
const consoleFn = level === "error" ? console.error : level === "warn" ? console.warn : level === "debug" ? console.debug : console.info;
|
|
109
|
+
consoleFn(JSON.stringify(output));
|
|
110
|
+
}
|
|
111
|
+
serializeError(error) {
|
|
112
|
+
if (error instanceof Error) {
|
|
113
|
+
return { name: error.name, message: error.message };
|
|
114
|
+
}
|
|
115
|
+
return error;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
73
119
|
// src/audit/InMemoryAuditRepository.ts
|
|
74
120
|
var InMemoryAuditRepository = class {
|
|
75
121
|
store = /* @__PURE__ */ new Map();
|
|
@@ -85,6 +131,12 @@ var InMemoryAuditRepository = class {
|
|
|
85
131
|
if (filter.action !== void 0) {
|
|
86
132
|
events = events.filter((e) => e.action === filter.action);
|
|
87
133
|
}
|
|
134
|
+
if (filter.entity !== void 0) {
|
|
135
|
+
events = events.filter((e) => e.entity === filter.entity);
|
|
136
|
+
}
|
|
137
|
+
if (filter.entityId !== void 0) {
|
|
138
|
+
events = events.filter((e) => e.entityId === filter.entityId);
|
|
139
|
+
}
|
|
88
140
|
if (filter.resourceType !== void 0) {
|
|
89
141
|
events = events.filter((e) => e.resource?.type === filter.resourceType);
|
|
90
142
|
}
|
|
@@ -107,6 +159,9 @@ var InMemoryAuditRepository = class {
|
|
|
107
159
|
}
|
|
108
160
|
return events;
|
|
109
161
|
}
|
|
162
|
+
async query(filter) {
|
|
163
|
+
return this.list(filter);
|
|
164
|
+
}
|
|
110
165
|
};
|
|
111
166
|
|
|
112
167
|
// src/audit/AuditRecorder.ts
|
|
@@ -124,6 +179,8 @@ var AuditRecorder = class {
|
|
|
124
179
|
actor: input.actor,
|
|
125
180
|
level: input.level ?? "info",
|
|
126
181
|
resource: input.resource,
|
|
182
|
+
entity: input.entity,
|
|
183
|
+
entityId: input.entityId,
|
|
127
184
|
before: input.before,
|
|
128
185
|
after: input.after,
|
|
129
186
|
metadata: input.metadata,
|
|
@@ -232,15 +289,2388 @@ var InMemoryEventBus = class {
|
|
|
232
289
|
this.handlers.get(eventType)?.delete(handler);
|
|
233
290
|
}
|
|
234
291
|
};
|
|
292
|
+
|
|
293
|
+
// src/metadata/EntityCapabilities.ts
|
|
294
|
+
var DEFAULT_CAPABILITIES = {
|
|
295
|
+
list: true,
|
|
296
|
+
detail: true,
|
|
297
|
+
create: false,
|
|
298
|
+
update: false,
|
|
299
|
+
clone: false,
|
|
300
|
+
delete: false,
|
|
301
|
+
softDelete: false,
|
|
302
|
+
export: false,
|
|
303
|
+
bulkActions: false
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// src/datasource/DatasourceRegistry.ts
|
|
307
|
+
var DatasourceRegistry = class {
|
|
308
|
+
registry = /* @__PURE__ */ new Map();
|
|
309
|
+
register(id, provider) {
|
|
310
|
+
this.registry.set(id, provider);
|
|
311
|
+
}
|
|
312
|
+
get(id) {
|
|
313
|
+
const provider = this.registry.get(id);
|
|
314
|
+
if (!provider) {
|
|
315
|
+
throw new MaestroError(
|
|
316
|
+
"CONFIGURATION_ERROR" /* CONFIGURATION_ERROR */,
|
|
317
|
+
`Datasource '${id}' not found. Registered datasources: [${this.ids().join(", ")}]`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return provider;
|
|
321
|
+
}
|
|
322
|
+
has(id) {
|
|
323
|
+
return this.registry.has(id);
|
|
324
|
+
}
|
|
325
|
+
ids() {
|
|
326
|
+
return Array.from(this.registry.keys());
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// src/datasource/InMemoryDatasourceProvider.ts
|
|
331
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
332
|
+
var InMemoryDatasourceProvider = class {
|
|
333
|
+
tables = /* @__PURE__ */ new Map();
|
|
334
|
+
seed(table, records, primaryKey = "id") {
|
|
335
|
+
if (!this.tables.has(table)) {
|
|
336
|
+
this.tables.set(table, /* @__PURE__ */ new Map());
|
|
337
|
+
}
|
|
338
|
+
const store = this.tables.get(table);
|
|
339
|
+
for (const record of records) {
|
|
340
|
+
const id = String(record[primaryKey] ?? randomUUID2());
|
|
341
|
+
store.set(id, { ...record, [primaryKey]: id });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
async list(context) {
|
|
345
|
+
const store = this.getStore(context.table);
|
|
346
|
+
let records = Array.from(store.values());
|
|
347
|
+
if (context.search?.term) {
|
|
348
|
+
const term = context.search.term.toLowerCase();
|
|
349
|
+
records = records.filter(
|
|
350
|
+
(r) => context.search.fields.some(
|
|
351
|
+
(field) => String(r[field] ?? "").toLowerCase().includes(term)
|
|
352
|
+
)
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
if (context.filters?.length) {
|
|
356
|
+
records = this.applyFilters(records, context.filters);
|
|
357
|
+
}
|
|
358
|
+
if (context.sort?.length) {
|
|
359
|
+
records = this.applySort(records, context.sort);
|
|
360
|
+
}
|
|
361
|
+
const total = records.length;
|
|
362
|
+
if (context.pagination) {
|
|
363
|
+
const p = context.pagination;
|
|
364
|
+
if (p.strategy === "page") {
|
|
365
|
+
const start = (p.page - 1) * p.pageSize;
|
|
366
|
+
records = records.slice(start, start + p.pageSize);
|
|
367
|
+
return { records, total, page: p.page, pageSize: p.pageSize, totalPages: Math.ceil(total / p.pageSize) };
|
|
368
|
+
}
|
|
369
|
+
if (p.strategy === "offset") {
|
|
370
|
+
records = records.slice(p.offset, p.offset + p.limit);
|
|
371
|
+
}
|
|
372
|
+
if (p.strategy === "cursor") {
|
|
373
|
+
const from = p.cursor ? parseInt(p.cursor, 10) : 0;
|
|
374
|
+
const sliced = records.slice(from, from + p.limit);
|
|
375
|
+
const nextIndex = from + p.limit;
|
|
376
|
+
return { records: sliced, total, nextCursor: nextIndex < total ? String(nextIndex) : void 0 };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return { records, total };
|
|
380
|
+
}
|
|
381
|
+
async findById(context) {
|
|
382
|
+
return this.getStore(context.table).get(context.id) ?? null;
|
|
383
|
+
}
|
|
384
|
+
async create(context) {
|
|
385
|
+
const store = this.getStore(context.table);
|
|
386
|
+
const id = String(context.data[context.primaryKey] ?? randomUUID2());
|
|
387
|
+
const record = { ...context.data, [context.primaryKey]: id };
|
|
388
|
+
store.set(id, record);
|
|
389
|
+
return record;
|
|
390
|
+
}
|
|
391
|
+
async update(context) {
|
|
392
|
+
const store = this.getStore(context.table);
|
|
393
|
+
const existing = store.get(context.id);
|
|
394
|
+
if (!existing) {
|
|
395
|
+
throw new MaestroError(
|
|
396
|
+
"NOT_FOUND" /* NOT_FOUND */,
|
|
397
|
+
`Record '${context.id}' not found in table '${context.table}'.`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
const updated = { ...existing, ...context.data, [context.primaryKey]: context.id };
|
|
401
|
+
store.set(context.id, updated);
|
|
402
|
+
return updated;
|
|
403
|
+
}
|
|
404
|
+
async delete(context) {
|
|
405
|
+
const store = this.getStore(context.table);
|
|
406
|
+
if (!store.has(context.id)) {
|
|
407
|
+
throw new MaestroError(
|
|
408
|
+
"NOT_FOUND" /* NOT_FOUND */,
|
|
409
|
+
`Record '${context.id}' not found in table '${context.table}'.`
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
store.delete(context.id);
|
|
413
|
+
}
|
|
414
|
+
async count(context) {
|
|
415
|
+
const result = await this.list({ ...context, pagination: void 0 });
|
|
416
|
+
return result.total;
|
|
417
|
+
}
|
|
418
|
+
getStore(table) {
|
|
419
|
+
if (!this.tables.has(table)) {
|
|
420
|
+
this.tables.set(table, /* @__PURE__ */ new Map());
|
|
421
|
+
}
|
|
422
|
+
return this.tables.get(table);
|
|
423
|
+
}
|
|
424
|
+
applyFilters(records, filters) {
|
|
425
|
+
return records.filter((record) => filters.every((f) => this.matchFilter(record, f)));
|
|
426
|
+
}
|
|
427
|
+
matchFilter(record, filter) {
|
|
428
|
+
const val = record[filter.field];
|
|
429
|
+
switch (filter.operator) {
|
|
430
|
+
case "equals":
|
|
431
|
+
return val === filter.value;
|
|
432
|
+
case "notEquals":
|
|
433
|
+
return val !== filter.value;
|
|
434
|
+
case "contains":
|
|
435
|
+
return String(val ?? "").toLowerCase().includes(String(filter.value ?? "").toLowerCase());
|
|
436
|
+
case "startsWith":
|
|
437
|
+
return String(val ?? "").startsWith(String(filter.value ?? ""));
|
|
438
|
+
case "endsWith":
|
|
439
|
+
return String(val ?? "").endsWith(String(filter.value ?? ""));
|
|
440
|
+
case "in":
|
|
441
|
+
return Array.isArray(filter.value) && filter.value.includes(val);
|
|
442
|
+
case "notIn":
|
|
443
|
+
return Array.isArray(filter.value) && !filter.value.includes(val);
|
|
444
|
+
case "gt":
|
|
445
|
+
return val > filter.value;
|
|
446
|
+
case "gte":
|
|
447
|
+
return val >= filter.value;
|
|
448
|
+
case "lt":
|
|
449
|
+
return val < filter.value;
|
|
450
|
+
case "lte":
|
|
451
|
+
return val <= filter.value;
|
|
452
|
+
case "between": {
|
|
453
|
+
const [min, max] = filter.value;
|
|
454
|
+
return val >= min && val <= max;
|
|
455
|
+
}
|
|
456
|
+
case "isNull":
|
|
457
|
+
return val === null || val === void 0;
|
|
458
|
+
case "isNotNull":
|
|
459
|
+
return val !== null && val !== void 0;
|
|
460
|
+
case "isTrue":
|
|
461
|
+
return val === true;
|
|
462
|
+
case "isFalse":
|
|
463
|
+
return val === false;
|
|
464
|
+
default:
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
applySort(records, sort) {
|
|
469
|
+
return [...records].sort((a, b) => {
|
|
470
|
+
for (const { field, direction } of sort) {
|
|
471
|
+
const aVal = a[field];
|
|
472
|
+
const bVal = b[field];
|
|
473
|
+
let cmp = 0;
|
|
474
|
+
if (aVal === null || aVal === void 0) cmp = 1;
|
|
475
|
+
else if (bVal === null || bVal === void 0) cmp = -1;
|
|
476
|
+
else if (aVal < bVal) cmp = -1;
|
|
477
|
+
else if (aVal > bVal) cmp = 1;
|
|
478
|
+
if (cmp !== 0) return direction === "desc" ? -cmp : cmp;
|
|
479
|
+
}
|
|
480
|
+
return 0;
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// src/operation/OperationRegistry.ts
|
|
486
|
+
var OperationRegistry = class {
|
|
487
|
+
operations = /* @__PURE__ */ new Map();
|
|
488
|
+
register(operation) {
|
|
489
|
+
if (this.operations.has(operation.id)) {
|
|
490
|
+
throw new MaestroError(
|
|
491
|
+
"CONFLICT" /* CONFLICT */,
|
|
492
|
+
`Operation '${operation.id}' is already registered.`
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
this.operations.set(operation.id, operation);
|
|
496
|
+
}
|
|
497
|
+
get(id) {
|
|
498
|
+
const op = this.operations.get(id);
|
|
499
|
+
if (!op) {
|
|
500
|
+
throw new MaestroError("NOT_FOUND" /* NOT_FOUND */, `Operation '${id}' not found.`);
|
|
501
|
+
}
|
|
502
|
+
return op;
|
|
503
|
+
}
|
|
504
|
+
find(id) {
|
|
505
|
+
return this.operations.get(id);
|
|
506
|
+
}
|
|
507
|
+
forEntity(entityId, scope) {
|
|
508
|
+
return Array.from(this.operations.values()).filter(
|
|
509
|
+
(op) => op.entity === entityId && (scope === void 0 || op.scope === scope)
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
all() {
|
|
513
|
+
return Array.from(this.operations.values());
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
// src/schema/SchemaValidator.ts
|
|
518
|
+
function validateMaestroConfig(config) {
|
|
519
|
+
const errors = [];
|
|
520
|
+
if (!config.datasources || Object.keys(config.datasources).length === 0) {
|
|
521
|
+
errors.push({ path: "datasources", message: "At least one datasource is required." });
|
|
522
|
+
}
|
|
523
|
+
if (!config.entities || config.entities.length === 0) {
|
|
524
|
+
errors.push({ path: "entities", message: "At least one entity is required." });
|
|
525
|
+
}
|
|
526
|
+
const entityIds = /* @__PURE__ */ new Set();
|
|
527
|
+
for (const entity of config.entities ?? []) {
|
|
528
|
+
validateEntity(entity, config, entityIds, errors);
|
|
529
|
+
if (entity.id) entityIds.add(entity.id);
|
|
530
|
+
}
|
|
531
|
+
for (const relation of config.relations ?? []) {
|
|
532
|
+
validateRelation(relation, entityIds, errors);
|
|
533
|
+
}
|
|
534
|
+
return { valid: errors.length === 0, errors };
|
|
535
|
+
}
|
|
536
|
+
function validateEntity(entity, config, existingIds, errors) {
|
|
537
|
+
const prefix = `entities[${entity.id ?? "?"}]`;
|
|
538
|
+
if (!entity.id) {
|
|
539
|
+
errors.push({ path: prefix, message: "Entity id is required." });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (existingIds.has(entity.id)) {
|
|
543
|
+
errors.push({ path: prefix, message: `Duplicate entity id '${entity.id}'.` });
|
|
544
|
+
}
|
|
545
|
+
if (!entity.label?.singular || !entity.label?.plural) {
|
|
546
|
+
errors.push({ path: `${prefix}.label`, message: "Entity label.singular and label.plural are required." });
|
|
547
|
+
}
|
|
548
|
+
if (!entity.source?.datasource) {
|
|
549
|
+
errors.push({ path: `${prefix}.source.datasource`, message: "Entity source.datasource is required." });
|
|
550
|
+
} else if (!config.datasources[entity.source.datasource]) {
|
|
551
|
+
errors.push({
|
|
552
|
+
path: `${prefix}.source.datasource`,
|
|
553
|
+
message: `Datasource '${entity.source.datasource}' referenced by entity '${entity.id}' is not configured.`
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
if (!entity.source?.table) {
|
|
557
|
+
errors.push({ path: `${prefix}.source.table`, message: "Entity source.table is required." });
|
|
558
|
+
}
|
|
559
|
+
if (!entity.fields || entity.fields.length === 0) {
|
|
560
|
+
errors.push({ path: `${prefix}.fields`, message: "Entity must have at least one field." });
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const fieldNames = /* @__PURE__ */ new Set();
|
|
564
|
+
for (const field of entity.fields) {
|
|
565
|
+
const fp = `${prefix}.fields[${field.name ?? "?"}]`;
|
|
566
|
+
if (!field.name) {
|
|
567
|
+
errors.push({ path: fp, message: "Field name is required." });
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (fieldNames.has(field.name)) {
|
|
571
|
+
errors.push({ path: fp, message: `Duplicate field name '${field.name}'.` });
|
|
572
|
+
} else {
|
|
573
|
+
fieldNames.add(field.name);
|
|
574
|
+
}
|
|
575
|
+
if (!field.label) {
|
|
576
|
+
errors.push({ path: `${fp}.label`, message: "Field label is required." });
|
|
577
|
+
}
|
|
578
|
+
if (!field.type) {
|
|
579
|
+
errors.push({ path: `${fp}.type`, message: "Field type is required." });
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
function validateRelation(relation, entityIds, errors) {
|
|
584
|
+
const prefix = `relations[${relation.id ?? "?"}]`;
|
|
585
|
+
if (!relation.id) {
|
|
586
|
+
errors.push({ path: prefix, message: "Relation id is required." });
|
|
587
|
+
}
|
|
588
|
+
if (!relation.from?.entity) {
|
|
589
|
+
errors.push({ path: `${prefix}.from.entity`, message: "Relation from.entity is required." });
|
|
590
|
+
} else if (!entityIds.has(relation.from.entity)) {
|
|
591
|
+
errors.push({ path: `${prefix}.from.entity`, message: `Entity '${relation.from.entity}' not found.` });
|
|
592
|
+
}
|
|
593
|
+
if (!relation.to?.entity) {
|
|
594
|
+
errors.push({ path: `${prefix}.to.entity`, message: "Relation to.entity is required." });
|
|
595
|
+
} else if (!entityIds.has(relation.to.entity)) {
|
|
596
|
+
errors.push({ path: `${prefix}.to.entity`, message: `Entity '${relation.to.entity}' not found.` });
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// src/engine/MetadataEngine.ts
|
|
601
|
+
var STRING_OPERATORS = [
|
|
602
|
+
"equals",
|
|
603
|
+
"notEquals",
|
|
604
|
+
"contains",
|
|
605
|
+
"startsWith",
|
|
606
|
+
"endsWith",
|
|
607
|
+
"in",
|
|
608
|
+
"notIn",
|
|
609
|
+
"isNull",
|
|
610
|
+
"isNotNull"
|
|
611
|
+
];
|
|
612
|
+
var NUMERIC_OPERATORS = [
|
|
613
|
+
"equals",
|
|
614
|
+
"notEquals",
|
|
615
|
+
"gt",
|
|
616
|
+
"gte",
|
|
617
|
+
"lt",
|
|
618
|
+
"lte",
|
|
619
|
+
"between",
|
|
620
|
+
"in",
|
|
621
|
+
"notIn",
|
|
622
|
+
"isNull",
|
|
623
|
+
"isNotNull"
|
|
624
|
+
];
|
|
625
|
+
var DATE_OPERATORS = [
|
|
626
|
+
"equals",
|
|
627
|
+
"notEquals",
|
|
628
|
+
"gt",
|
|
629
|
+
"gte",
|
|
630
|
+
"lt",
|
|
631
|
+
"lte",
|
|
632
|
+
"between",
|
|
633
|
+
"isNull",
|
|
634
|
+
"isNotNull"
|
|
635
|
+
];
|
|
636
|
+
var ENUM_OPERATORS = ["equals", "notEquals", "in", "notIn", "isNull", "isNotNull"];
|
|
637
|
+
var BOOLEAN_OPERATORS = ["isTrue", "isFalse", "isNull", "isNotNull"];
|
|
638
|
+
var NULL_OPERATORS = ["isNull", "isNotNull"];
|
|
639
|
+
function filterOperatorsForType(type) {
|
|
640
|
+
switch (type) {
|
|
641
|
+
case "string":
|
|
642
|
+
case "text":
|
|
643
|
+
case "email":
|
|
644
|
+
case "phone":
|
|
645
|
+
case "url":
|
|
646
|
+
case "document":
|
|
647
|
+
return STRING_OPERATORS;
|
|
648
|
+
case "number":
|
|
649
|
+
case "integer":
|
|
650
|
+
case "decimal":
|
|
651
|
+
case "currency":
|
|
652
|
+
return NUMERIC_OPERATORS;
|
|
653
|
+
case "boolean":
|
|
654
|
+
return BOOLEAN_OPERATORS;
|
|
655
|
+
case "date":
|
|
656
|
+
case "datetime":
|
|
657
|
+
case "time":
|
|
658
|
+
return DATE_OPERATORS;
|
|
659
|
+
case "enum":
|
|
660
|
+
return ENUM_OPERATORS;
|
|
661
|
+
case "uuid":
|
|
662
|
+
return ENUM_OPERATORS;
|
|
663
|
+
case "json":
|
|
664
|
+
case "array":
|
|
665
|
+
case "relation":
|
|
666
|
+
return NULL_OPERATORS;
|
|
667
|
+
default:
|
|
668
|
+
return ["equals", "notEquals", "isNull", "isNotNull"];
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
function buildContextActions(entity, operations) {
|
|
672
|
+
const caps = { ...DEFAULT_CAPABILITIES, ...entity.capabilities };
|
|
673
|
+
const actions = [];
|
|
674
|
+
if (caps.update) {
|
|
675
|
+
actions.push({ id: "edit", label: "Edit", permission: `entity.${entity.id}.update` });
|
|
676
|
+
}
|
|
677
|
+
if (caps.clone) {
|
|
678
|
+
actions.push({ id: "clone", label: "Clone", permission: `entity.${entity.id}.clone` });
|
|
679
|
+
}
|
|
680
|
+
if (caps.softDelete) {
|
|
681
|
+
actions.push({
|
|
682
|
+
id: "soft-delete",
|
|
683
|
+
label: "Deactivate",
|
|
684
|
+
permission: `entity.${entity.id}.softDelete`,
|
|
685
|
+
condition: "when-active",
|
|
686
|
+
style: "warning"
|
|
687
|
+
});
|
|
688
|
+
actions.push({
|
|
689
|
+
id: "restore",
|
|
690
|
+
label: "Restore",
|
|
691
|
+
permission: `entity.${entity.id}.restore`,
|
|
692
|
+
condition: "when-inactive"
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
if (caps.delete) {
|
|
696
|
+
actions.push({
|
|
697
|
+
id: "hard-delete",
|
|
698
|
+
label: "Delete permanently",
|
|
699
|
+
permission: `entity.${entity.id}.delete`,
|
|
700
|
+
style: "danger"
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
for (const op of operations) {
|
|
704
|
+
if (op.entity === entity.id && op.scope === "record") {
|
|
705
|
+
actions.push({
|
|
706
|
+
id: op.id,
|
|
707
|
+
label: op.label,
|
|
708
|
+
permission: op.requiredPermission ?? `operation.${op.id}`,
|
|
709
|
+
operationId: op.id
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return actions;
|
|
714
|
+
}
|
|
715
|
+
function buildBulkOperations(entity, operations) {
|
|
716
|
+
return operations.filter((op) => op.entity === entity.id && op.scope === "bulk").map((op) => op.id);
|
|
717
|
+
}
|
|
718
|
+
var MetadataEngine = class {
|
|
719
|
+
normalize(config) {
|
|
720
|
+
const operations = config.operations ?? [];
|
|
721
|
+
const entities = config.entities.map((e) => this.normalizeEntity(e, operations));
|
|
722
|
+
const relations = config.relations ?? [];
|
|
723
|
+
const entityRelationMap = /* @__PURE__ */ new Map();
|
|
724
|
+
for (const rel of relations) {
|
|
725
|
+
const addToMap = (entityId) => {
|
|
726
|
+
if (!entityRelationMap.has(entityId)) entityRelationMap.set(entityId, []);
|
|
727
|
+
entityRelationMap.get(entityId).push(rel.id);
|
|
728
|
+
};
|
|
729
|
+
addToMap(rel.from.entity);
|
|
730
|
+
if (rel.to.entity !== rel.from.entity) addToMap(rel.to.entity);
|
|
731
|
+
}
|
|
732
|
+
const enrichedEntities = entities.map((e) => ({
|
|
733
|
+
...e,
|
|
734
|
+
relations: entityRelationMap.get(e.id) ?? []
|
|
735
|
+
}));
|
|
736
|
+
const normalizedOps = operations.map((op) => ({
|
|
737
|
+
id: op.id,
|
|
738
|
+
label: op.label,
|
|
739
|
+
description: op.description,
|
|
740
|
+
entity: op.entity,
|
|
741
|
+
scope: op.scope,
|
|
742
|
+
requiresConfirmation: op.requiresConfirmation ?? false,
|
|
743
|
+
confirmationMessage: op.confirmationMessage,
|
|
744
|
+
requiresTypedConfirmation: op.requiresTypedConfirmation ?? false,
|
|
745
|
+
requiredPermission: op.requiredPermission
|
|
746
|
+
}));
|
|
747
|
+
return {
|
|
748
|
+
entities: enrichedEntities,
|
|
749
|
+
relations,
|
|
750
|
+
operations: normalizedOps,
|
|
751
|
+
policies: config.policies ?? { roles: {} }
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
normalizeEntity(schema, operations) {
|
|
755
|
+
const capabilities = { ...DEFAULT_CAPABILITIES, ...schema.capabilities };
|
|
756
|
+
const primaryKey = schema.source.primaryKey ?? "id";
|
|
757
|
+
const displayField = schema.displayField ?? schema.fields.find((f) => !f.hidden)?.name ?? primaryKey;
|
|
758
|
+
return {
|
|
759
|
+
id: schema.id,
|
|
760
|
+
label: schema.label,
|
|
761
|
+
description: schema.description,
|
|
762
|
+
datasource: schema.source.datasource,
|
|
763
|
+
table: schema.source.table,
|
|
764
|
+
primaryKey,
|
|
765
|
+
displayField,
|
|
766
|
+
fields: schema.fields.map((f) => this.normalizeField(f)),
|
|
767
|
+
capabilities,
|
|
768
|
+
softDelete: schema.softDelete,
|
|
769
|
+
defaultSort: schema.defaultSort,
|
|
770
|
+
search: schema.search,
|
|
771
|
+
relations: [],
|
|
772
|
+
contextActions: buildContextActions(schema, operations),
|
|
773
|
+
bulkOperations: buildBulkOperations(schema, operations),
|
|
774
|
+
exportConfig: schema.exportConfig
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
normalizeField(schema) {
|
|
778
|
+
return {
|
|
779
|
+
name: schema.name,
|
|
780
|
+
label: schema.label,
|
|
781
|
+
type: schema.type,
|
|
782
|
+
required: schema.required ?? false,
|
|
783
|
+
readonly: schema.readonly ?? false,
|
|
784
|
+
hidden: schema.hidden ?? false,
|
|
785
|
+
sensitive: schema.sensitive ?? false,
|
|
786
|
+
searchable: schema.searchable ?? false,
|
|
787
|
+
sortable: schema.sortable ?? false,
|
|
788
|
+
filterable: schema.filterable ?? false,
|
|
789
|
+
exportable: schema.exportable ?? true,
|
|
790
|
+
filterOperators: schema.filterOperators ?? filterOperatorsForType(schema.type),
|
|
791
|
+
description: schema.description,
|
|
792
|
+
enumOptions: schema.enumOptions,
|
|
793
|
+
relationEntity: schema.relationEntity,
|
|
794
|
+
mask: schema.mask,
|
|
795
|
+
list: {
|
|
796
|
+
visible: schema.list?.visible ?? !(schema.hidden ?? false),
|
|
797
|
+
width: schema.list?.width,
|
|
798
|
+
align: schema.list?.align,
|
|
799
|
+
order: schema.list?.order
|
|
800
|
+
},
|
|
801
|
+
detail: {
|
|
802
|
+
visible: schema.detail?.visible ?? !(schema.hidden ?? false),
|
|
803
|
+
group: schema.detail?.group,
|
|
804
|
+
order: schema.detail?.order
|
|
805
|
+
},
|
|
806
|
+
form: {
|
|
807
|
+
creatable: schema.form?.creatable ?? true,
|
|
808
|
+
editable: schema.form?.editable ?? !(schema.readonly ?? false),
|
|
809
|
+
cloneable: schema.form?.cloneable ?? !(schema.readonly ?? false),
|
|
810
|
+
placeholder: schema.form?.placeholder,
|
|
811
|
+
helpText: schema.form?.helpText,
|
|
812
|
+
section: schema.form?.section,
|
|
813
|
+
order: schema.form?.order
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
// src/engine/MaestroEngine.ts
|
|
820
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
821
|
+
|
|
822
|
+
// src/export/CsvExportProvider.ts
|
|
823
|
+
var CsvExportProvider = class {
|
|
824
|
+
export(entity, records, options) {
|
|
825
|
+
const fields = this.resolveFields(entity, options);
|
|
826
|
+
const headers = fields.map((f) => f.label);
|
|
827
|
+
const rows = records.map(
|
|
828
|
+
(record) => fields.map((field) => this.formatValue(record[field.name], field, options))
|
|
829
|
+
);
|
|
830
|
+
const csvLines = [headers, ...rows].map(
|
|
831
|
+
(row) => row.map((cell) => this.escapeCsvCell(String(cell ?? ""))).join(",")
|
|
832
|
+
);
|
|
833
|
+
const bom = options.bom !== false ? "\uFEFF" : "";
|
|
834
|
+
const data = bom + csvLines.join("\r\n");
|
|
835
|
+
const datestamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
836
|
+
const filename = `${entity.id}_export_${datestamp}.csv`;
|
|
837
|
+
return {
|
|
838
|
+
format: "csv",
|
|
839
|
+
data,
|
|
840
|
+
filename,
|
|
841
|
+
recordCount: records.length,
|
|
842
|
+
mimeType: "text/csv;charset=utf-8"
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
resolveFields(entity, options) {
|
|
846
|
+
let fields = entity.fields.filter((f) => f.exportable && !f.hidden);
|
|
847
|
+
if (options.includeFields?.length) {
|
|
848
|
+
const include = new Set(options.includeFields);
|
|
849
|
+
fields = fields.filter((f) => include.has(f.name));
|
|
850
|
+
}
|
|
851
|
+
if (options.excludeFields?.length) {
|
|
852
|
+
const exclude = new Set(options.excludeFields);
|
|
853
|
+
fields = fields.filter((f) => !exclude.has(f.name));
|
|
854
|
+
}
|
|
855
|
+
return fields;
|
|
856
|
+
}
|
|
857
|
+
formatValue(value, field, options) {
|
|
858
|
+
if (value === null || value === void 0) return "";
|
|
859
|
+
if (field.sensitive) return "***";
|
|
860
|
+
switch (field.type) {
|
|
861
|
+
case "boolean":
|
|
862
|
+
return value ? "Sim" : "N\xE3o";
|
|
863
|
+
case "date":
|
|
864
|
+
case "datetime": {
|
|
865
|
+
if (value instanceof Date) {
|
|
866
|
+
return options.dateFormat ? this.formatDate(value, options.dateFormat) : value.toISOString();
|
|
867
|
+
}
|
|
868
|
+
return String(value);
|
|
869
|
+
}
|
|
870
|
+
case "enum": {
|
|
871
|
+
const option = field.enumOptions?.find((o) => o.value === value);
|
|
872
|
+
return option ? option.label : String(value);
|
|
873
|
+
}
|
|
874
|
+
case "json":
|
|
875
|
+
return JSON.stringify(value);
|
|
876
|
+
default:
|
|
877
|
+
return String(value);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
formatDate(date, format) {
|
|
881
|
+
return format.replace("YYYY", String(date.getFullYear())).replace("MM", String(date.getMonth() + 1).padStart(2, "0")).replace("DD", String(date.getDate()).padStart(2, "0")).replace("HH", String(date.getHours()).padStart(2, "0")).replace("mm", String(date.getMinutes()).padStart(2, "0")).replace("ss", String(date.getSeconds()).padStart(2, "0"));
|
|
882
|
+
}
|
|
883
|
+
escapeCsvCell(value) {
|
|
884
|
+
if (value.includes(",") || value.includes('"') || value.includes("\n") || value.includes("\r")) {
|
|
885
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
886
|
+
}
|
|
887
|
+
return value;
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
// src/engine/MaestroEngine.ts
|
|
892
|
+
var MaestroEngine = class {
|
|
893
|
+
constructor(metadata, datasources, operations, audit) {
|
|
894
|
+
this.metadata = metadata;
|
|
895
|
+
this.datasources = datasources;
|
|
896
|
+
this.operations = operations;
|
|
897
|
+
this.audit = audit;
|
|
898
|
+
this.rbac = new RbacEngine(metadata.policies);
|
|
899
|
+
}
|
|
900
|
+
metadata;
|
|
901
|
+
datasources;
|
|
902
|
+
operations;
|
|
903
|
+
audit;
|
|
904
|
+
rbac;
|
|
905
|
+
getMetadata() {
|
|
906
|
+
return this.metadata;
|
|
907
|
+
}
|
|
908
|
+
async list(entityId, query, actor) {
|
|
909
|
+
const entity = this.requireEntity(entityId);
|
|
910
|
+
this.requireCapability(entity, "list", actor, `entity.${entityId}.list`);
|
|
911
|
+
const provider = this.datasources.get(entity.datasource);
|
|
912
|
+
const result = await provider.list({
|
|
913
|
+
table: entity.table,
|
|
914
|
+
primaryKey: entity.primaryKey,
|
|
915
|
+
filters: query.filters,
|
|
916
|
+
sort: query.sort ?? (entity.defaultSort ? [entity.defaultSort] : void 0),
|
|
917
|
+
pagination: query.pagination,
|
|
918
|
+
search: query.search
|
|
919
|
+
});
|
|
920
|
+
await this.recordAudit(`entity.${entityId}.list`, actor, { type: entityId, id: "*" }, "debug", {
|
|
921
|
+
total: result.total
|
|
922
|
+
});
|
|
923
|
+
return result;
|
|
924
|
+
}
|
|
925
|
+
async findById(entityId, id, actor) {
|
|
926
|
+
const entity = this.requireEntity(entityId);
|
|
927
|
+
this.requireCapability(entity, "detail", actor, `entity.${entityId}.detail`);
|
|
928
|
+
const provider = this.datasources.get(entity.datasource);
|
|
929
|
+
const record = await provider.findById({ table: entity.table, primaryKey: entity.primaryKey, id });
|
|
930
|
+
if (record) {
|
|
931
|
+
await this.recordAudit(`entity.${entityId}.detail`, actor, { type: entityId, id }, "debug");
|
|
932
|
+
}
|
|
933
|
+
return record;
|
|
934
|
+
}
|
|
935
|
+
async create(entityId, data, actor) {
|
|
936
|
+
const entity = this.requireEntity(entityId);
|
|
937
|
+
this.requireCapability(entity, "create", actor, `entity.${entityId}.create`);
|
|
938
|
+
const provider = this.datasources.get(entity.datasource);
|
|
939
|
+
const created = await provider.create({
|
|
940
|
+
table: entity.table,
|
|
941
|
+
primaryKey: entity.primaryKey,
|
|
942
|
+
data: { ...data, [entity.primaryKey]: data[entity.primaryKey] ?? randomUUID3() }
|
|
943
|
+
});
|
|
944
|
+
await this.recordAudit(
|
|
945
|
+
`entity.${entityId}.create`,
|
|
946
|
+
actor,
|
|
947
|
+
{ type: entityId, id: String(created[entity.primaryKey]) },
|
|
948
|
+
"info",
|
|
949
|
+
void 0,
|
|
950
|
+
void 0,
|
|
951
|
+
created
|
|
952
|
+
);
|
|
953
|
+
return created;
|
|
954
|
+
}
|
|
955
|
+
async update(entityId, id, data, actor) {
|
|
956
|
+
const entity = this.requireEntity(entityId);
|
|
957
|
+
this.requireCapability(entity, "update", actor, `entity.${entityId}.update`);
|
|
958
|
+
const provider = this.datasources.get(entity.datasource);
|
|
959
|
+
const before = await provider.findById({ table: entity.table, primaryKey: entity.primaryKey, id });
|
|
960
|
+
const updated = await provider.update({ table: entity.table, primaryKey: entity.primaryKey, id, data });
|
|
961
|
+
await this.recordAudit(
|
|
962
|
+
`entity.${entityId}.update`,
|
|
963
|
+
actor,
|
|
964
|
+
{ type: entityId, id },
|
|
965
|
+
"info",
|
|
966
|
+
void 0,
|
|
967
|
+
before ?? void 0,
|
|
968
|
+
updated
|
|
969
|
+
);
|
|
970
|
+
return updated;
|
|
971
|
+
}
|
|
972
|
+
async clone(entityId, id, actor) {
|
|
973
|
+
const entity = this.requireEntity(entityId);
|
|
974
|
+
this.requireCapability(entity, "clone", actor, `entity.${entityId}.clone`);
|
|
975
|
+
const provider = this.datasources.get(entity.datasource);
|
|
976
|
+
const source = await provider.findById({ table: entity.table, primaryKey: entity.primaryKey, id });
|
|
977
|
+
if (!source) {
|
|
978
|
+
throw new MaestroError("NOT_FOUND" /* NOT_FOUND */, `Record '${id}' not found in entity '${entityId}'.`);
|
|
979
|
+
}
|
|
980
|
+
const cloneableFields = entity.fields.filter((f) => f.form.cloneable && f.name !== entity.primaryKey);
|
|
981
|
+
const cloneData = {};
|
|
982
|
+
for (const field of cloneableFields) {
|
|
983
|
+
if (source[field.name] !== void 0) cloneData[field.name] = source[field.name];
|
|
984
|
+
}
|
|
985
|
+
const created = await provider.create({
|
|
986
|
+
table: entity.table,
|
|
987
|
+
primaryKey: entity.primaryKey,
|
|
988
|
+
data: { ...cloneData, [entity.primaryKey]: randomUUID3() }
|
|
989
|
+
});
|
|
990
|
+
await this.recordAudit(
|
|
991
|
+
`entity.${entityId}.clone`,
|
|
992
|
+
actor,
|
|
993
|
+
{ type: entityId, id: String(created[entity.primaryKey]) },
|
|
994
|
+
"info",
|
|
995
|
+
{ sourceId: id }
|
|
996
|
+
);
|
|
997
|
+
return created;
|
|
998
|
+
}
|
|
999
|
+
async softDelete(entityId, id, actor) {
|
|
1000
|
+
const entity = this.requireEntity(entityId);
|
|
1001
|
+
this.requireCapability(entity, "softDelete", actor, `entity.${entityId}.softDelete`);
|
|
1002
|
+
if (!entity.softDelete) {
|
|
1003
|
+
throw new MaestroError(
|
|
1004
|
+
"CONFIGURATION_ERROR" /* CONFIGURATION_ERROR */,
|
|
1005
|
+
`Entity '${entityId}' does not have softDelete configured.`
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
const provider = this.datasources.get(entity.datasource);
|
|
1009
|
+
await provider.update({
|
|
1010
|
+
table: entity.table,
|
|
1011
|
+
primaryKey: entity.primaryKey,
|
|
1012
|
+
id,
|
|
1013
|
+
data: { [entity.softDelete.field]: entity.softDelete.inactiveValue }
|
|
1014
|
+
});
|
|
1015
|
+
await this.recordAudit(`entity.${entityId}.softDelete`, actor, { type: entityId, id }, "warn");
|
|
1016
|
+
}
|
|
1017
|
+
async restore(entityId, id, actor) {
|
|
1018
|
+
const entity = this.requireEntity(entityId);
|
|
1019
|
+
if (!entity.softDelete) {
|
|
1020
|
+
throw new MaestroError(
|
|
1021
|
+
"CONFIGURATION_ERROR" /* CONFIGURATION_ERROR */,
|
|
1022
|
+
`Entity '${entityId}' does not have softDelete configured.`
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
const permission = `entity.${entityId}.restore`;
|
|
1026
|
+
if (!this.rbac.can(actor, permission)) {
|
|
1027
|
+
throw new MaestroError("PERMISSION_DENIED" /* PERMISSION_DENIED */, `Permission denied: ${permission}`);
|
|
1028
|
+
}
|
|
1029
|
+
const provider = this.datasources.get(entity.datasource);
|
|
1030
|
+
await provider.update({
|
|
1031
|
+
table: entity.table,
|
|
1032
|
+
primaryKey: entity.primaryKey,
|
|
1033
|
+
id,
|
|
1034
|
+
data: { [entity.softDelete.field]: entity.softDelete.activeValue }
|
|
1035
|
+
});
|
|
1036
|
+
await this.recordAudit(`entity.${entityId}.restore`, actor, { type: entityId, id }, "warn");
|
|
1037
|
+
}
|
|
1038
|
+
async hardDelete(entityId, id, actor) {
|
|
1039
|
+
const entity = this.requireEntity(entityId);
|
|
1040
|
+
this.requireCapability(entity, "delete", actor, `entity.${entityId}.delete`);
|
|
1041
|
+
const provider = this.datasources.get(entity.datasource);
|
|
1042
|
+
const before = await provider.findById({ table: entity.table, primaryKey: entity.primaryKey, id });
|
|
1043
|
+
await provider.delete({ table: entity.table, primaryKey: entity.primaryKey, id });
|
|
1044
|
+
await this.recordAudit(
|
|
1045
|
+
`entity.${entityId}.delete`,
|
|
1046
|
+
actor,
|
|
1047
|
+
{ type: entityId, id },
|
|
1048
|
+
"warn",
|
|
1049
|
+
void 0,
|
|
1050
|
+
before ?? void 0
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
async export(entityId, query, actor, format = "csv") {
|
|
1054
|
+
const entity = this.requireEntity(entityId);
|
|
1055
|
+
this.requireCapability(entity, "export", actor, `entity.${entityId}.export`);
|
|
1056
|
+
const provider = this.datasources.get(entity.datasource);
|
|
1057
|
+
const result = await provider.list({
|
|
1058
|
+
table: entity.table,
|
|
1059
|
+
primaryKey: entity.primaryKey,
|
|
1060
|
+
filters: query.filters,
|
|
1061
|
+
sort: query.sort,
|
|
1062
|
+
search: query.search
|
|
1063
|
+
});
|
|
1064
|
+
const exportProvider = new CsvExportProvider();
|
|
1065
|
+
const exportResult = exportProvider.export(entity, result.records, { format });
|
|
1066
|
+
await this.recordAudit(
|
|
1067
|
+
`entity.${entityId}.export`,
|
|
1068
|
+
actor,
|
|
1069
|
+
{ type: entityId, id: "*" },
|
|
1070
|
+
"info",
|
|
1071
|
+
{ recordCount: exportResult.recordCount, format }
|
|
1072
|
+
);
|
|
1073
|
+
return exportResult;
|
|
1074
|
+
}
|
|
1075
|
+
async executeOperation(operationId, context, actor) {
|
|
1076
|
+
const operation = this.operations.find(operationId);
|
|
1077
|
+
if (!operation) {
|
|
1078
|
+
throw new MaestroError("NOT_FOUND" /* NOT_FOUND */, `Operation '${operationId}' not found.`);
|
|
1079
|
+
}
|
|
1080
|
+
if (operation.requiredPermission && !this.rbac.can(actor, operation.requiredPermission)) {
|
|
1081
|
+
throw new MaestroError(
|
|
1082
|
+
"PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
1083
|
+
`Permission denied: ${operation.requiredPermission}`
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
const correlationId = context.correlationId ?? randomUUID3();
|
|
1087
|
+
const result = await operation.execute({ ...context, actor, correlationId });
|
|
1088
|
+
const entityMeta = context.entityId ? this.metadata.entities.find((e) => e.id === context.entityId) : void 0;
|
|
1089
|
+
const recordId = context.record && entityMeta ? String(context.record[entityMeta.primaryKey] ?? "unknown") : "*";
|
|
1090
|
+
await this.recordAudit(
|
|
1091
|
+
`operation.${operationId}`,
|
|
1092
|
+
actor,
|
|
1093
|
+
context.entityId ? { type: context.entityId, id: recordId } : void 0,
|
|
1094
|
+
result.success ? "info" : "error",
|
|
1095
|
+
{ success: result.success },
|
|
1096
|
+
void 0,
|
|
1097
|
+
void 0,
|
|
1098
|
+
correlationId
|
|
1099
|
+
);
|
|
1100
|
+
return result;
|
|
1101
|
+
}
|
|
1102
|
+
requireEntity(entityId) {
|
|
1103
|
+
const entity = this.metadata.entities.find((e) => e.id === entityId);
|
|
1104
|
+
if (!entity) {
|
|
1105
|
+
throw new MaestroError("NOT_FOUND" /* NOT_FOUND */, `Entity '${entityId}' not found.`);
|
|
1106
|
+
}
|
|
1107
|
+
return entity;
|
|
1108
|
+
}
|
|
1109
|
+
requireCapability(entity, capability, actor, permission) {
|
|
1110
|
+
if (!entity.capabilities[capability]) {
|
|
1111
|
+
throw new MaestroError(
|
|
1112
|
+
"CONFIGURATION_ERROR" /* CONFIGURATION_ERROR */,
|
|
1113
|
+
`Capability '${String(capability)}' is not enabled for entity '${entity.id}'.`
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
if (!this.rbac.can(actor, permission)) {
|
|
1117
|
+
throw new MaestroError("PERMISSION_DENIED" /* PERMISSION_DENIED */, `Permission denied: ${permission}`);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
async recordAudit(action, actor, resource, level = "info", metadata, before, after, correlationId) {
|
|
1121
|
+
await this.audit?.record({
|
|
1122
|
+
action,
|
|
1123
|
+
actor,
|
|
1124
|
+
resource,
|
|
1125
|
+
level,
|
|
1126
|
+
metadata,
|
|
1127
|
+
before,
|
|
1128
|
+
after,
|
|
1129
|
+
correlationId
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
// src/engine/createMaestro.ts
|
|
1135
|
+
function createMaestro(config) {
|
|
1136
|
+
const validation = validateMaestroConfig(config);
|
|
1137
|
+
if (!validation.valid) {
|
|
1138
|
+
const messages = validation.errors.map((e) => `${e.path}: ${e.message}`).join("; ");
|
|
1139
|
+
throw new MaestroError("CONFIGURATION_ERROR" /* CONFIGURATION_ERROR */, `Invalid Maestro configuration: ${messages}`);
|
|
1140
|
+
}
|
|
1141
|
+
const metadataEngine = new MetadataEngine();
|
|
1142
|
+
const metadata = metadataEngine.normalize(config);
|
|
1143
|
+
const datasources = new DatasourceRegistry();
|
|
1144
|
+
for (const [id, provider] of Object.entries(config.datasources)) {
|
|
1145
|
+
datasources.register(id, provider);
|
|
1146
|
+
}
|
|
1147
|
+
const operations = new OperationRegistry();
|
|
1148
|
+
for (const operation of config.operations ?? []) {
|
|
1149
|
+
operations.register(operation);
|
|
1150
|
+
}
|
|
1151
|
+
const audit = config.audit ? new AuditRecorder(config.audit) : void 0;
|
|
1152
|
+
return new MaestroEngine(metadata, datasources, operations, audit);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// src/introspection/utils.ts
|
|
1156
|
+
var DISPLAY_FIELD_PRIORITY = [
|
|
1157
|
+
"name",
|
|
1158
|
+
"title",
|
|
1159
|
+
"username",
|
|
1160
|
+
"email",
|
|
1161
|
+
"code",
|
|
1162
|
+
"slug",
|
|
1163
|
+
"label",
|
|
1164
|
+
"first_name",
|
|
1165
|
+
"firstname",
|
|
1166
|
+
"full_name",
|
|
1167
|
+
"fullname",
|
|
1168
|
+
"subject"
|
|
1169
|
+
];
|
|
1170
|
+
var TIMESTAMP_PATTERN = /^(created_at|createdat|created_date|createddate|updated_at|updatedat|updated_date|updateddate|deleted_at|deletedat|modified_at|modifiedat)$/i;
|
|
1171
|
+
var SEARCH_CANDIDATE_PATTERN = /^(name|title|description|email|username|first_name|firstname|last_name|lastname|full_name|fullname|label|code|slug|reference|subject)$/i;
|
|
1172
|
+
function inferFieldType(nativeType, fieldName) {
|
|
1173
|
+
const nt = nativeType.toLowerCase().replace(/\(.*\)/, "").trim();
|
|
1174
|
+
const fn = fieldName.toLowerCase();
|
|
1175
|
+
if (["uuid", "uniqueidentifier"].includes(nt)) return "uuid";
|
|
1176
|
+
if (["boolean", "bool", "bit"].includes(nt)) return "boolean";
|
|
1177
|
+
if (nt === "date") return "date";
|
|
1178
|
+
if ([
|
|
1179
|
+
"timestamp",
|
|
1180
|
+
"timestamptz",
|
|
1181
|
+
"timestamp with time zone",
|
|
1182
|
+
"timestamp without time zone",
|
|
1183
|
+
"datetime",
|
|
1184
|
+
"datetime2"
|
|
1185
|
+
].includes(nt))
|
|
1186
|
+
return "datetime";
|
|
1187
|
+
if (["time", "timetz", "time with time zone", "time without time zone"].includes(nt))
|
|
1188
|
+
return "time";
|
|
1189
|
+
if (["json", "jsonb"].includes(nt)) return "json";
|
|
1190
|
+
if (nt.includes("[]") || nt === "array") return "array";
|
|
1191
|
+
if ([
|
|
1192
|
+
"int",
|
|
1193
|
+
"int2",
|
|
1194
|
+
"int4",
|
|
1195
|
+
"int8",
|
|
1196
|
+
"int16",
|
|
1197
|
+
"integer",
|
|
1198
|
+
"bigint",
|
|
1199
|
+
"smallint",
|
|
1200
|
+
"tinyint",
|
|
1201
|
+
"mediumint",
|
|
1202
|
+
"serial",
|
|
1203
|
+
"bigserial",
|
|
1204
|
+
"smallserial"
|
|
1205
|
+
].includes(nt))
|
|
1206
|
+
return "integer";
|
|
1207
|
+
if (["float", "float4", "float8", "double", "double precision", "real"].includes(nt))
|
|
1208
|
+
return "number";
|
|
1209
|
+
if (["decimal", "numeric", "money", "smallmoney"].includes(nt)) {
|
|
1210
|
+
if (/^(price|amount|value|total|cost|fee|salary|balance|revenue|profit|tax)$|_(price|amount|value|total|cost|fee)$/.test(
|
|
1211
|
+
fn
|
|
1212
|
+
))
|
|
1213
|
+
return "currency";
|
|
1214
|
+
return "decimal";
|
|
1215
|
+
}
|
|
1216
|
+
if (["text", "mediumtext", "longtext", "clob"].includes(nt)) return "text";
|
|
1217
|
+
if (fn === "email" || fn.endsWith("_email")) return "email";
|
|
1218
|
+
if (fn === "phone" || fn === "telephone" || fn.endsWith("_phone") || fn.endsWith("_tel"))
|
|
1219
|
+
return "phone";
|
|
1220
|
+
if (fn === "url" || fn === "website" || fn.endsWith("_url") || fn.endsWith("_website"))
|
|
1221
|
+
return "url";
|
|
1222
|
+
if (/^(doc|document|cpf|cnpj|ssn|nif|rg)$|_(doc|document|cpf|cnpj|ssn|rg)$/.test(fn))
|
|
1223
|
+
return "document";
|
|
1224
|
+
return "string";
|
|
1225
|
+
}
|
|
1226
|
+
function isTimestampField(fieldName, fieldType) {
|
|
1227
|
+
return TIMESTAMP_PATTERN.test(fieldName) && (fieldType === "datetime" || fieldType === "date");
|
|
1228
|
+
}
|
|
1229
|
+
function isSoftDeleteCandidate(fieldName, fieldType, nullable) {
|
|
1230
|
+
const fn = fieldName.toLowerCase();
|
|
1231
|
+
if ((fn === "deleted_at" || fn === "deletedat") && fieldType === "datetime" && nullable)
|
|
1232
|
+
return true;
|
|
1233
|
+
if ((fn === "is_deleted" || fn === "isdeleted" || fn === "deleted") && fieldType === "boolean")
|
|
1234
|
+
return true;
|
|
1235
|
+
if ((fn === "is_active" || fn === "isactive" || fn === "active") && fieldType === "boolean")
|
|
1236
|
+
return true;
|
|
1237
|
+
return false;
|
|
1238
|
+
}
|
|
1239
|
+
function isSearchCandidate(fieldName, fieldType, isPrimaryKey) {
|
|
1240
|
+
if (isPrimaryKey) return false;
|
|
1241
|
+
if (!["string", "text", "email", "phone", "url"].includes(fieldType)) return false;
|
|
1242
|
+
return SEARCH_CANDIDATE_PATTERN.test(fieldName);
|
|
1243
|
+
}
|
|
1244
|
+
function tableNameToEntityId(table) {
|
|
1245
|
+
return table.replace(/([A-Z])/g, (match, c, offset) => offset > 0 ? `-${c}` : c).replace(/_/g, "-").replace(/--+/g, "-").toLowerCase();
|
|
1246
|
+
}
|
|
1247
|
+
function humanizeWord(word) {
|
|
1248
|
+
if (!word) return "";
|
|
1249
|
+
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
|
1250
|
+
}
|
|
1251
|
+
function tableNameToLabel(table) {
|
|
1252
|
+
const words = table.replace(/([A-Z])/g, " $1").replace(/_/g, " ").trim().split(/\s+/).filter(Boolean).map(humanizeWord);
|
|
1253
|
+
const plural = words.join(" ");
|
|
1254
|
+
const lastWord = words[words.length - 1];
|
|
1255
|
+
let singularLast;
|
|
1256
|
+
if (lastWord.toLowerCase().endsWith("ies")) {
|
|
1257
|
+
singularLast = lastWord.slice(0, -3) + "y";
|
|
1258
|
+
} else if (lastWord.toLowerCase().endsWith("ses") || lastWord.toLowerCase().endsWith("xes") || lastWord.toLowerCase().endsWith("ches") || lastWord.toLowerCase().endsWith("shes")) {
|
|
1259
|
+
singularLast = lastWord.slice(0, -2);
|
|
1260
|
+
} else if (lastWord.toLowerCase().endsWith("s") && !lastWord.toLowerCase().endsWith("ss")) {
|
|
1261
|
+
singularLast = lastWord.slice(0, -1);
|
|
1262
|
+
} else {
|
|
1263
|
+
singularLast = lastWord;
|
|
1264
|
+
}
|
|
1265
|
+
const singular = [...words.slice(0, -1), singularLast].join(" ");
|
|
1266
|
+
return { singular, plural };
|
|
1267
|
+
}
|
|
1268
|
+
function humanizeFieldName(name) {
|
|
1269
|
+
return name.replace(/([A-Z])/g, " $1").replace(/_/g, " ").trim().replace(/\b\w/g, (c) => c.toUpperCase());
|
|
1270
|
+
}
|
|
1271
|
+
function detectDisplayField(fields) {
|
|
1272
|
+
for (const priority of DISPLAY_FIELD_PRIORITY) {
|
|
1273
|
+
const match = fields.find((f) => f.name.toLowerCase() === priority && !f.isPrimaryKey);
|
|
1274
|
+
if (match) return match.name;
|
|
1275
|
+
}
|
|
1276
|
+
const fallback = fields.find(
|
|
1277
|
+
(f) => !f.isPrimaryKey && ["string", "text", "email"].includes(f.type)
|
|
1278
|
+
);
|
|
1279
|
+
return fallback?.name;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// src/hybrid-loader/HybridLoader.ts
|
|
1283
|
+
function fieldFromIntrospection(field) {
|
|
1284
|
+
return {
|
|
1285
|
+
name: field.name,
|
|
1286
|
+
label: humanizeFieldName(field.name),
|
|
1287
|
+
type: field.type,
|
|
1288
|
+
required: !field.nullable && !field.isPrimaryKey,
|
|
1289
|
+
readonly: field.isPrimaryKey,
|
|
1290
|
+
hidden: field.isPrimaryKey,
|
|
1291
|
+
searchable: field.candidateForSearch ?? false,
|
|
1292
|
+
sortable: !field.isPrimaryKey && !field.isTimestamp,
|
|
1293
|
+
filterable: field.isIndexed ?? false,
|
|
1294
|
+
exportable: !field.isPrimaryKey
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
function mergeFields(introspectedFields, overrideFields, strategy) {
|
|
1298
|
+
const overrideMap = new Map(overrideFields.map((f) => [f.name, f]));
|
|
1299
|
+
const introspectedMap = new Map(introspectedFields.map((f) => [f.name, f]));
|
|
1300
|
+
const result = [];
|
|
1301
|
+
for (const override of overrideFields) {
|
|
1302
|
+
const introspected = introspectedMap.get(override.name);
|
|
1303
|
+
if (introspected) {
|
|
1304
|
+
result.push(override);
|
|
1305
|
+
} else {
|
|
1306
|
+
result.push(override);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
if (strategy === "extend") {
|
|
1310
|
+
for (const introspected of introspectedFields) {
|
|
1311
|
+
if (!overrideMap.has(introspected.name)) {
|
|
1312
|
+
result.push(fieldFromIntrospection(introspected));
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
return result;
|
|
1317
|
+
}
|
|
1318
|
+
function detectSoftDelete(fields) {
|
|
1319
|
+
const PRIORITY = ["deleted_at", "deletedat", "is_deleted", "isdeleted", "deleted", "is_active", "isactive", "active"];
|
|
1320
|
+
for (const candidate of PRIORITY) {
|
|
1321
|
+
const field = fields.find((f) => f.name.toLowerCase() === candidate && f.candidateForSoftDelete);
|
|
1322
|
+
if (field) {
|
|
1323
|
+
const fn = field.name.toLowerCase();
|
|
1324
|
+
if (fn === "deleted_at" || fn === "deletedat") {
|
|
1325
|
+
return { field: field.name, activeValue: null, inactiveValue: "timestamp" };
|
|
1326
|
+
}
|
|
1327
|
+
if (fn === "is_deleted" || fn === "isdeleted" || fn === "deleted") {
|
|
1328
|
+
return { field: field.name, activeValue: false, inactiveValue: true };
|
|
1329
|
+
}
|
|
1330
|
+
return { field: field.name, activeValue: true, inactiveValue: false };
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
return void 0;
|
|
1334
|
+
}
|
|
1335
|
+
function detectSearchConfig(fields) {
|
|
1336
|
+
const searchFields = fields.filter((f) => f.candidateForSearch).map((f) => f.name);
|
|
1337
|
+
return searchFields.length > 0 ? { fields: searchFields } : void 0;
|
|
1338
|
+
}
|
|
1339
|
+
function entityFromIntrospection(entity, datasource) {
|
|
1340
|
+
const pk = entity.fields.find((f) => f.isPrimaryKey)?.name ?? "id";
|
|
1341
|
+
const displayField = detectDisplayField(entity.fields);
|
|
1342
|
+
const softDelete = detectSoftDelete(entity.fields);
|
|
1343
|
+
const search = detectSearchConfig(entity.fields);
|
|
1344
|
+
return {
|
|
1345
|
+
id: tableNameToEntityId(entity.table),
|
|
1346
|
+
label: tableNameToLabel(entity.table),
|
|
1347
|
+
source: {
|
|
1348
|
+
datasource,
|
|
1349
|
+
table: entity.table,
|
|
1350
|
+
primaryKey: pk
|
|
1351
|
+
},
|
|
1352
|
+
displayField,
|
|
1353
|
+
fields: entity.fields.map(fieldFromIntrospection),
|
|
1354
|
+
...softDelete && { softDelete },
|
|
1355
|
+
...search && { search }
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
function mergeEntity(introspected, override, strategy) {
|
|
1359
|
+
return {
|
|
1360
|
+
...override,
|
|
1361
|
+
fields: mergeFields(introspected.fields, override.fields, strategy)
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
function introspectedRelationToSchema(rel, entityIdByTable) {
|
|
1365
|
+
const fromEntity = entityIdByTable.get(rel.from.table);
|
|
1366
|
+
const toEntity = entityIdByTable.get(rel.to.table);
|
|
1367
|
+
if (!fromEntity || !toEntity) return null;
|
|
1368
|
+
return {
|
|
1369
|
+
id: rel.id,
|
|
1370
|
+
type: rel.type,
|
|
1371
|
+
from: { entity: fromEntity, field: rel.from.field },
|
|
1372
|
+
to: { entity: toEntity, field: rel.to.field },
|
|
1373
|
+
label: `${tableNameToLabel(rel.to.table).plural}`
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
function mergeIntrospectionWithOverrides(options) {
|
|
1377
|
+
const { introspection, overrides, datasources, defaultDatasource, strategy = "extend" } = options;
|
|
1378
|
+
const overrideByTable = /* @__PURE__ */ new Map();
|
|
1379
|
+
const overrideById = /* @__PURE__ */ new Map();
|
|
1380
|
+
for (const entity of overrides.entities) {
|
|
1381
|
+
overrideByTable.set(entity.source.table, entity);
|
|
1382
|
+
overrideById.set(entity.id, entity);
|
|
1383
|
+
}
|
|
1384
|
+
const entityIdByTable = /* @__PURE__ */ new Map();
|
|
1385
|
+
const mergedEntities = [];
|
|
1386
|
+
const handledOverrideIds = /* @__PURE__ */ new Set();
|
|
1387
|
+
for (const introspectedEntity of introspection.entities) {
|
|
1388
|
+
const override = overrideByTable.get(introspectedEntity.table) ?? overrideById.get(tableNameToEntityId(introspectedEntity.table));
|
|
1389
|
+
if (override) {
|
|
1390
|
+
handledOverrideIds.add(override.id);
|
|
1391
|
+
mergedEntities.push(mergeEntity(introspectedEntity, override, strategy));
|
|
1392
|
+
entityIdByTable.set(introspectedEntity.table, override.id);
|
|
1393
|
+
} else {
|
|
1394
|
+
const generated = entityFromIntrospection(introspectedEntity, defaultDatasource);
|
|
1395
|
+
mergedEntities.push(generated);
|
|
1396
|
+
entityIdByTable.set(introspectedEntity.table, generated.id);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
for (const override of overrides.entities) {
|
|
1400
|
+
if (!handledOverrideIds.has(override.id)) {
|
|
1401
|
+
mergedEntities.push(override);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
const overrideRelationIds = new Set(overrides.relations.map((r) => r.id));
|
|
1405
|
+
const mergedRelations = [...overrides.relations];
|
|
1406
|
+
for (const rel of introspection.relations) {
|
|
1407
|
+
if (!overrideRelationIds.has(rel.id)) {
|
|
1408
|
+
const schema = introspectedRelationToSchema(rel, entityIdByTable);
|
|
1409
|
+
if (schema) mergedRelations.push(schema);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
return {
|
|
1413
|
+
datasources,
|
|
1414
|
+
entities: mergedEntities,
|
|
1415
|
+
relations: mergedRelations,
|
|
1416
|
+
policies: overrides.policies,
|
|
1417
|
+
operations: options.operations,
|
|
1418
|
+
audit: options.audit,
|
|
1419
|
+
logger: options.logger
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// src/engine/createMaestroFromIntrospection.ts
|
|
1424
|
+
async function createMaestroFromIntrospection(options) {
|
|
1425
|
+
const introspection = await options.provider.introspect();
|
|
1426
|
+
const overrides = options.overrides ?? { entities: [], relations: [] };
|
|
1427
|
+
const datasources = options.datasources ?? {};
|
|
1428
|
+
const defaultDatasource = options.defaultDatasource ?? "";
|
|
1429
|
+
const config = mergeIntrospectionWithOverrides({
|
|
1430
|
+
introspection,
|
|
1431
|
+
overrides,
|
|
1432
|
+
datasources,
|
|
1433
|
+
defaultDatasource,
|
|
1434
|
+
strategy: options.mergeStrategy ?? "extend",
|
|
1435
|
+
operations: options.operations,
|
|
1436
|
+
audit: options.audit,
|
|
1437
|
+
logger: options.logger
|
|
1438
|
+
});
|
|
1439
|
+
if (options.policies) {
|
|
1440
|
+
config.policies = options.policies;
|
|
1441
|
+
}
|
|
1442
|
+
return createMaestro(config);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// src/http/QueryStringParser.ts
|
|
1446
|
+
var VALID_OPERATORS = /* @__PURE__ */ new Set([
|
|
1447
|
+
"equals",
|
|
1448
|
+
"notEquals",
|
|
1449
|
+
"contains",
|
|
1450
|
+
"startsWith",
|
|
1451
|
+
"endsWith",
|
|
1452
|
+
"in",
|
|
1453
|
+
"notIn",
|
|
1454
|
+
"gt",
|
|
1455
|
+
"gte",
|
|
1456
|
+
"lt",
|
|
1457
|
+
"lte",
|
|
1458
|
+
"between",
|
|
1459
|
+
"isNull",
|
|
1460
|
+
"isNotNull",
|
|
1461
|
+
"isTrue",
|
|
1462
|
+
"isFalse"
|
|
1463
|
+
]);
|
|
1464
|
+
function parseStr(val) {
|
|
1465
|
+
return Array.isArray(val) ? val[0] : val;
|
|
1466
|
+
}
|
|
1467
|
+
function parseNum(val) {
|
|
1468
|
+
const s = parseStr(val);
|
|
1469
|
+
if (!s) return void 0;
|
|
1470
|
+
const n = Number(s);
|
|
1471
|
+
return Number.isNaN(n) ? void 0 : n;
|
|
1472
|
+
}
|
|
1473
|
+
function parseFilterValue(operator, value) {
|
|
1474
|
+
if (value === void 0) return void 0;
|
|
1475
|
+
if (operator === "in" || operator === "notIn") return value.split(",");
|
|
1476
|
+
if (operator === "between") {
|
|
1477
|
+
const [from, to] = value.split(",");
|
|
1478
|
+
return [from, to];
|
|
1479
|
+
}
|
|
1480
|
+
return value;
|
|
1481
|
+
}
|
|
1482
|
+
function parseQueryInput(query) {
|
|
1483
|
+
const result = {};
|
|
1484
|
+
const filterParam = query["filter"] ?? query["filters"];
|
|
1485
|
+
if (filterParam) {
|
|
1486
|
+
const rawFilters = Array.isArray(filterParam) ? filterParam : [filterParam];
|
|
1487
|
+
const filters = [];
|
|
1488
|
+
for (const raw of rawFilters) {
|
|
1489
|
+
const colonIdx = raw.indexOf(":");
|
|
1490
|
+
if (colonIdx === -1) continue;
|
|
1491
|
+
const field = raw.slice(0, colonIdx);
|
|
1492
|
+
const rest = raw.slice(colonIdx + 1);
|
|
1493
|
+
const colonIdx2 = rest.indexOf(":");
|
|
1494
|
+
const op = colonIdx2 === -1 ? rest : rest.slice(0, colonIdx2);
|
|
1495
|
+
const valueRaw = colonIdx2 === -1 ? void 0 : rest.slice(colonIdx2 + 1);
|
|
1496
|
+
if (!field || !VALID_OPERATORS.has(op)) continue;
|
|
1497
|
+
const operator = op;
|
|
1498
|
+
filters.push({ field, operator, value: parseFilterValue(operator, valueRaw) });
|
|
1499
|
+
}
|
|
1500
|
+
if (filters.length > 0) result.filters = filters;
|
|
1501
|
+
}
|
|
1502
|
+
const sortParam = query["sort"];
|
|
1503
|
+
if (sortParam) {
|
|
1504
|
+
const rawSorts = Array.isArray(sortParam) ? sortParam : [sortParam];
|
|
1505
|
+
const sort = [];
|
|
1506
|
+
for (const raw of rawSorts) {
|
|
1507
|
+
const colonIdx = raw.indexOf(":");
|
|
1508
|
+
const field = colonIdx === -1 ? raw : raw.slice(0, colonIdx);
|
|
1509
|
+
const direction = colonIdx === -1 ? "asc" : raw.slice(colonIdx + 1);
|
|
1510
|
+
if (!field) continue;
|
|
1511
|
+
sort.push({ field, direction: direction === "desc" ? "desc" : "asc" });
|
|
1512
|
+
}
|
|
1513
|
+
if (sort.length > 0) result.sort = sort;
|
|
1514
|
+
}
|
|
1515
|
+
const page = parseNum(query["page"]);
|
|
1516
|
+
const pageSize = parseNum(query["pageSize"]);
|
|
1517
|
+
const offset = parseNum(query["offset"]);
|
|
1518
|
+
const limit = parseNum(query["limit"]);
|
|
1519
|
+
const cursor = parseStr(query["cursor"]);
|
|
1520
|
+
if (page !== void 0) {
|
|
1521
|
+
const pagination = { strategy: "page", page, pageSize: pageSize ?? 20 };
|
|
1522
|
+
result.pagination = pagination;
|
|
1523
|
+
} else if (offset !== void 0) {
|
|
1524
|
+
const pagination = { strategy: "offset", offset, limit: limit ?? 20 };
|
|
1525
|
+
result.pagination = pagination;
|
|
1526
|
+
} else if (cursor !== void 0) {
|
|
1527
|
+
const pagination = { strategy: "cursor", cursor, limit: limit ?? 20 };
|
|
1528
|
+
result.pagination = pagination;
|
|
1529
|
+
}
|
|
1530
|
+
const searchTerm = parseStr(query["search"]);
|
|
1531
|
+
if (searchTerm) {
|
|
1532
|
+
const searchFields = parseStr(query["searchFields"]);
|
|
1533
|
+
result.search = {
|
|
1534
|
+
term: searchTerm,
|
|
1535
|
+
fields: searchFields ? searchFields.split(",") : []
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
return result;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// src/http/createMaestroHttpHandlers.ts
|
|
1542
|
+
var HTTP_STATUS = {
|
|
1543
|
+
["PERMISSION_DENIED" /* PERMISSION_DENIED */]: 403,
|
|
1544
|
+
["NOT_FOUND" /* NOT_FOUND */]: 404,
|
|
1545
|
+
["CONFLICT" /* CONFLICT */]: 409,
|
|
1546
|
+
["VALIDATION_ERROR" /* VALIDATION_ERROR */]: 400,
|
|
1547
|
+
["CONFIGURATION_ERROR" /* CONFIGURATION_ERROR */]: 400,
|
|
1548
|
+
["INTERNAL_ERROR" /* INTERNAL_ERROR */]: 500
|
|
1549
|
+
};
|
|
1550
|
+
function errorResponse(err) {
|
|
1551
|
+
if (err instanceof MaestroError) {
|
|
1552
|
+
return {
|
|
1553
|
+
status: HTTP_STATUS[err.code] ?? 500,
|
|
1554
|
+
body: { error: err.code, message: err.message }
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
const message = err instanceof Error ? err.message : "Internal server error";
|
|
1558
|
+
return { status: 500, body: { error: "INTERNAL_ERROR", message } };
|
|
1559
|
+
}
|
|
1560
|
+
function createMaestroHttpHandlers(engine, options) {
|
|
1561
|
+
const resolve = options.actorResolver;
|
|
1562
|
+
const list = async (req) => {
|
|
1563
|
+
try {
|
|
1564
|
+
const { actor } = await resolve(req);
|
|
1565
|
+
const result = await engine.list(req.params["entity"], parseQueryInput(req.query), actor);
|
|
1566
|
+
return { status: 200, body: result };
|
|
1567
|
+
} catch (err) {
|
|
1568
|
+
return errorResponse(err);
|
|
1569
|
+
}
|
|
1570
|
+
};
|
|
1571
|
+
const findById = async (req) => {
|
|
1572
|
+
try {
|
|
1573
|
+
const { actor } = await resolve(req);
|
|
1574
|
+
const record = await engine.findById(req.params["entity"], req.params["id"], actor);
|
|
1575
|
+
if (!record) {
|
|
1576
|
+
return { status: 404, body: { error: "NOT_FOUND", message: `Record '${req.params["id"]}' not found.` } };
|
|
1577
|
+
}
|
|
1578
|
+
return { status: 200, body: record };
|
|
1579
|
+
} catch (err) {
|
|
1580
|
+
return errorResponse(err);
|
|
1581
|
+
}
|
|
1582
|
+
};
|
|
1583
|
+
const create = async (req) => {
|
|
1584
|
+
try {
|
|
1585
|
+
const { actor } = await resolve(req);
|
|
1586
|
+
const created = await engine.create(req.params["entity"], req.body, actor);
|
|
1587
|
+
return { status: 201, body: created };
|
|
1588
|
+
} catch (err) {
|
|
1589
|
+
return errorResponse(err);
|
|
1590
|
+
}
|
|
1591
|
+
};
|
|
1592
|
+
const update = async (req) => {
|
|
1593
|
+
try {
|
|
1594
|
+
const { actor } = await resolve(req);
|
|
1595
|
+
const updated = await engine.update(
|
|
1596
|
+
req.params["entity"],
|
|
1597
|
+
req.params["id"],
|
|
1598
|
+
req.body,
|
|
1599
|
+
actor
|
|
1600
|
+
);
|
|
1601
|
+
return { status: 200, body: updated };
|
|
1602
|
+
} catch (err) {
|
|
1603
|
+
return errorResponse(err);
|
|
1604
|
+
}
|
|
1605
|
+
};
|
|
1606
|
+
const clone = async (req) => {
|
|
1607
|
+
try {
|
|
1608
|
+
const { actor } = await resolve(req);
|
|
1609
|
+
const cloned = await engine.clone(req.params["entity"], req.params["id"], actor);
|
|
1610
|
+
return { status: 201, body: cloned };
|
|
1611
|
+
} catch (err) {
|
|
1612
|
+
return errorResponse(err);
|
|
1613
|
+
}
|
|
1614
|
+
};
|
|
1615
|
+
const softDelete = async (req) => {
|
|
1616
|
+
try {
|
|
1617
|
+
const { actor } = await resolve(req);
|
|
1618
|
+
await engine.softDelete(req.params["entity"], req.params["id"], actor);
|
|
1619
|
+
return { status: 204, body: null };
|
|
1620
|
+
} catch (err) {
|
|
1621
|
+
return errorResponse(err);
|
|
1622
|
+
}
|
|
1623
|
+
};
|
|
1624
|
+
const restore = async (req) => {
|
|
1625
|
+
try {
|
|
1626
|
+
const { actor } = await resolve(req);
|
|
1627
|
+
await engine.restore(req.params["entity"], req.params["id"], actor);
|
|
1628
|
+
return { status: 200, body: { success: true } };
|
|
1629
|
+
} catch (err) {
|
|
1630
|
+
return errorResponse(err);
|
|
1631
|
+
}
|
|
1632
|
+
};
|
|
1633
|
+
const hardDelete = async (req) => {
|
|
1634
|
+
try {
|
|
1635
|
+
const { actor } = await resolve(req);
|
|
1636
|
+
await engine.hardDelete(req.params["entity"], req.params["id"], actor);
|
|
1637
|
+
return { status: 204, body: null };
|
|
1638
|
+
} catch (err) {
|
|
1639
|
+
return errorResponse(err);
|
|
1640
|
+
}
|
|
1641
|
+
};
|
|
1642
|
+
const exportHandler = async (req) => {
|
|
1643
|
+
try {
|
|
1644
|
+
const { actor } = await resolve(req);
|
|
1645
|
+
const rawFormat = Array.isArray(req.query["format"]) ? req.query["format"][0] : req.query["format"];
|
|
1646
|
+
const format = rawFormat === "json" ? "json" : "csv";
|
|
1647
|
+
const exportResult = await engine.export(req.params["entity"], parseQueryInput(req.query), actor, format);
|
|
1648
|
+
return {
|
|
1649
|
+
status: 200,
|
|
1650
|
+
body: exportResult.data,
|
|
1651
|
+
headers: {
|
|
1652
|
+
"Content-Type": exportResult.mimeType,
|
|
1653
|
+
"Content-Disposition": `attachment; filename="${exportResult.filename}"`
|
|
1654
|
+
}
|
|
1655
|
+
};
|
|
1656
|
+
} catch (err) {
|
|
1657
|
+
return errorResponse(err);
|
|
1658
|
+
}
|
|
1659
|
+
};
|
|
1660
|
+
const executeOperation = async (req) => {
|
|
1661
|
+
try {
|
|
1662
|
+
const { actor, correlationId } = await resolve(req);
|
|
1663
|
+
const body = req.body ?? {};
|
|
1664
|
+
const result = await engine.executeOperation(
|
|
1665
|
+
req.params["operationId"],
|
|
1666
|
+
{
|
|
1667
|
+
actor,
|
|
1668
|
+
correlationId,
|
|
1669
|
+
entityId: body["entityId"],
|
|
1670
|
+
record: body["record"],
|
|
1671
|
+
input: body["input"]
|
|
1672
|
+
},
|
|
1673
|
+
actor
|
|
1674
|
+
);
|
|
1675
|
+
return { status: result.success ? 200 : 422, body: result };
|
|
1676
|
+
} catch (err) {
|
|
1677
|
+
return errorResponse(err);
|
|
1678
|
+
}
|
|
1679
|
+
};
|
|
1680
|
+
const getMetadata = async () => {
|
|
1681
|
+
return { status: 200, body: engine.getMetadata() };
|
|
1682
|
+
};
|
|
1683
|
+
const getEntityMetadata = async (req) => {
|
|
1684
|
+
const entityId = req.params["entity"];
|
|
1685
|
+
const meta = engine.getMetadata();
|
|
1686
|
+
const entity = meta.entities.find((e) => e.id === entityId);
|
|
1687
|
+
if (!entity) {
|
|
1688
|
+
return { status: 404, body: { error: "NOT_FOUND", message: `Entity '${entityId}' not found.` } };
|
|
1689
|
+
}
|
|
1690
|
+
return { status: 200, body: entity };
|
|
1691
|
+
};
|
|
1692
|
+
const health = async () => {
|
|
1693
|
+
return { status: 200, body: { status: "ok" } };
|
|
1694
|
+
};
|
|
1695
|
+
return {
|
|
1696
|
+
list,
|
|
1697
|
+
findById,
|
|
1698
|
+
create,
|
|
1699
|
+
update,
|
|
1700
|
+
clone,
|
|
1701
|
+
softDelete,
|
|
1702
|
+
restore,
|
|
1703
|
+
hardDelete,
|
|
1704
|
+
export: exportHandler,
|
|
1705
|
+
executeOperation,
|
|
1706
|
+
getMetadata,
|
|
1707
|
+
getEntityMetadata,
|
|
1708
|
+
health
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// src/config-loader/MaestroFileLoader.ts
|
|
1713
|
+
import { readFile, readdir, access } from "fs/promises";
|
|
1714
|
+
import { join, extname } from "path";
|
|
1715
|
+
var defaultFileReader = {
|
|
1716
|
+
readFile: (p) => readFile(p, "utf-8"),
|
|
1717
|
+
readDir: async (p) => {
|
|
1718
|
+
const entries = await readdir(p, { withFileTypes: true });
|
|
1719
|
+
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
1720
|
+
},
|
|
1721
|
+
exists: async (p) => {
|
|
1722
|
+
try {
|
|
1723
|
+
await access(p);
|
|
1724
|
+
return true;
|
|
1725
|
+
} catch {
|
|
1726
|
+
return false;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
};
|
|
1730
|
+
function parseContent(content, ext, yamlParser) {
|
|
1731
|
+
if (ext === ".yml" || ext === ".yaml") {
|
|
1732
|
+
if (!yamlParser) {
|
|
1733
|
+
throw new Error(
|
|
1734
|
+
"YAML files require a yamlParser option. Install js-yaml and pass { parse: jsYaml.load } as yamlParser."
|
|
1735
|
+
);
|
|
1736
|
+
}
|
|
1737
|
+
return yamlParser.parse(content);
|
|
1738
|
+
}
|
|
1739
|
+
return JSON.parse(content);
|
|
1740
|
+
}
|
|
1741
|
+
async function readAllInDir(dir, suffix, reader, yamlParser) {
|
|
1742
|
+
const exists = await reader.exists(dir);
|
|
1743
|
+
if (!exists) return [];
|
|
1744
|
+
const files = await reader.readDir(dir);
|
|
1745
|
+
const matched = files.filter(
|
|
1746
|
+
(f) => f.endsWith(`${suffix}.json`) || f.endsWith(`${suffix}.yml`) || f.endsWith(`${suffix}.yaml`)
|
|
1747
|
+
);
|
|
1748
|
+
const results = [];
|
|
1749
|
+
for (const f of matched) {
|
|
1750
|
+
const content = await reader.readFile(join(dir, f));
|
|
1751
|
+
results.push(parseContent(content, extname(f), yamlParser));
|
|
1752
|
+
}
|
|
1753
|
+
return results;
|
|
1754
|
+
}
|
|
1755
|
+
async function loadMaestroConfig(options) {
|
|
1756
|
+
const reader = options.fileReader ?? defaultFileReader;
|
|
1757
|
+
const { rootDir, yamlParser } = options;
|
|
1758
|
+
const [entitiesRaw, relationsRaw] = await Promise.all([
|
|
1759
|
+
readAllInDir(join(rootDir, "entities"), ".entity", reader, yamlParser),
|
|
1760
|
+
readAllInDir(join(rootDir, "relations"), ".relation", reader, yamlParser)
|
|
1761
|
+
]);
|
|
1762
|
+
let policies;
|
|
1763
|
+
const policyCandidates = [
|
|
1764
|
+
"policies/rbac.policy.json",
|
|
1765
|
+
"policies/rbac.policy.yml",
|
|
1766
|
+
"policies/rbac.policy.yaml"
|
|
1767
|
+
];
|
|
1768
|
+
for (const candidate of policyCandidates) {
|
|
1769
|
+
const fullPath = join(rootDir, candidate);
|
|
1770
|
+
if (await reader.exists(fullPath)) {
|
|
1771
|
+
const content = await reader.readFile(fullPath);
|
|
1772
|
+
policies = parseContent(content, extname(candidate), yamlParser);
|
|
1773
|
+
break;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
return {
|
|
1777
|
+
entities: entitiesRaw,
|
|
1778
|
+
relations: relationsRaw,
|
|
1779
|
+
policies
|
|
1780
|
+
};
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// src/config-generator/ConfigGenerator.ts
|
|
1784
|
+
function detectSoftDelete2(fields) {
|
|
1785
|
+
const PRIORITY = [
|
|
1786
|
+
"deleted_at",
|
|
1787
|
+
"deletedat",
|
|
1788
|
+
"is_deleted",
|
|
1789
|
+
"isdeleted",
|
|
1790
|
+
"deleted",
|
|
1791
|
+
"is_active",
|
|
1792
|
+
"isactive",
|
|
1793
|
+
"active"
|
|
1794
|
+
];
|
|
1795
|
+
for (const candidate of PRIORITY) {
|
|
1796
|
+
const field = fields.find(
|
|
1797
|
+
(f) => f.name.toLowerCase() === candidate && f.candidateForSoftDelete
|
|
1798
|
+
);
|
|
1799
|
+
if (!field) continue;
|
|
1800
|
+
const fn = field.name.toLowerCase();
|
|
1801
|
+
if (fn === "deleted_at" || fn === "deletedat") {
|
|
1802
|
+
return { field: field.name, activeValue: null, inactiveValue: "timestamp" };
|
|
1803
|
+
}
|
|
1804
|
+
if (fn === "is_deleted" || fn === "isdeleted" || fn === "deleted") {
|
|
1805
|
+
return { field: field.name, activeValue: false, inactiveValue: true };
|
|
1806
|
+
}
|
|
1807
|
+
return { field: field.name, activeValue: true, inactiveValue: false };
|
|
1808
|
+
}
|
|
1809
|
+
return void 0;
|
|
1810
|
+
}
|
|
1811
|
+
function fieldToSchema(field) {
|
|
1812
|
+
return {
|
|
1813
|
+
name: field.name,
|
|
1814
|
+
label: humanizeFieldName(field.name),
|
|
1815
|
+
type: field.type,
|
|
1816
|
+
required: !field.nullable && !field.isPrimaryKey,
|
|
1817
|
+
readonly: field.isPrimaryKey,
|
|
1818
|
+
hidden: field.isPrimaryKey,
|
|
1819
|
+
searchable: field.candidateForSearch ?? false,
|
|
1820
|
+
sortable: !field.isPrimaryKey && !field.isTimestamp,
|
|
1821
|
+
filterable: field.isIndexed ?? false,
|
|
1822
|
+
exportable: !field.isPrimaryKey
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
function generateEntityConfig(entity, datasource) {
|
|
1826
|
+
const pk = entity.fields.find((f) => f.isPrimaryKey)?.name ?? "id";
|
|
1827
|
+
const displayField = detectDisplayField(entity.fields);
|
|
1828
|
+
const softDelete = detectSoftDelete2(entity.fields);
|
|
1829
|
+
const searchFields = entity.fields.filter((f) => f.candidateForSearch).map((f) => f.name);
|
|
1830
|
+
return {
|
|
1831
|
+
id: tableNameToEntityId(entity.table),
|
|
1832
|
+
label: tableNameToLabel(entity.table),
|
|
1833
|
+
source: {
|
|
1834
|
+
datasource,
|
|
1835
|
+
table: entity.table,
|
|
1836
|
+
primaryKey: pk
|
|
1837
|
+
},
|
|
1838
|
+
...displayField && { displayField },
|
|
1839
|
+
fields: entity.fields.map(fieldToSchema),
|
|
1840
|
+
...softDelete && { softDelete },
|
|
1841
|
+
...searchFields.length > 0 && { search: { fields: searchFields } }
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
function generateRelationConfig(relation, entityIdByTable) {
|
|
1845
|
+
const fromEntity = entityIdByTable.get(relation.from.table);
|
|
1846
|
+
const toEntity = entityIdByTable.get(relation.to.table);
|
|
1847
|
+
if (!fromEntity || !toEntity) return null;
|
|
1848
|
+
return {
|
|
1849
|
+
id: relation.id,
|
|
1850
|
+
type: relation.type,
|
|
1851
|
+
from: { entity: fromEntity, field: relation.from.field },
|
|
1852
|
+
to: { entity: toEntity, field: relation.to.field },
|
|
1853
|
+
label: tableNameToLabel(relation.to.table).plural
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
function generateAllConfigs(result, datasource) {
|
|
1857
|
+
const entities = {};
|
|
1858
|
+
const entityIdByTable = /* @__PURE__ */ new Map();
|
|
1859
|
+
for (const entity of result.entities) {
|
|
1860
|
+
const schema = generateEntityConfig(entity, datasource);
|
|
1861
|
+
entities[schema.id] = schema;
|
|
1862
|
+
entityIdByTable.set(entity.table, schema.id);
|
|
1863
|
+
}
|
|
1864
|
+
const relations = {};
|
|
1865
|
+
for (const relation of result.relations) {
|
|
1866
|
+
const schema = generateRelationConfig(relation, entityIdByTable);
|
|
1867
|
+
if (schema) relations[schema.id] = schema;
|
|
1868
|
+
}
|
|
1869
|
+
return { entities, relations };
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// src/diff/DiffEngine.ts
|
|
1873
|
+
var DiffEngine = class {
|
|
1874
|
+
compare(previous, current, options = {}) {
|
|
1875
|
+
const changes = [];
|
|
1876
|
+
const prevEntityMap = new Map(previous.entities.map((e) => [e.table, e]));
|
|
1877
|
+
const currEntityMap = new Map(current.entities.map((e) => [e.table, e]));
|
|
1878
|
+
for (const [table, entity] of currEntityMap) {
|
|
1879
|
+
if (!prevEntityMap.has(table)) {
|
|
1880
|
+
changes.push({
|
|
1881
|
+
category: "entity",
|
|
1882
|
+
kind: "entity_added",
|
|
1883
|
+
impact: "SAFE",
|
|
1884
|
+
entityTable: table,
|
|
1885
|
+
current: entity
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
for (const [table, entity] of prevEntityMap) {
|
|
1890
|
+
if (!currEntityMap.has(table)) {
|
|
1891
|
+
changes.push({
|
|
1892
|
+
category: "entity",
|
|
1893
|
+
kind: "entity_removed",
|
|
1894
|
+
impact: "BREAKING",
|
|
1895
|
+
entityTable: table,
|
|
1896
|
+
previous: entity
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
for (const [table, prevEntity] of prevEntityMap) {
|
|
1901
|
+
const currEntity = currEntityMap.get(table);
|
|
1902
|
+
if (!currEntity) continue;
|
|
1903
|
+
const prevLabel = options.previousEntityLabels?.get(table) ?? tableNameToLabel(prevEntity.table).singular;
|
|
1904
|
+
const currLabel = options.currentEntityLabels?.get(table) ?? tableNameToLabel(currEntity.table).singular;
|
|
1905
|
+
if (prevLabel !== currLabel) {
|
|
1906
|
+
changes.push({
|
|
1907
|
+
category: "entity",
|
|
1908
|
+
kind: "entity_label_changed",
|
|
1909
|
+
impact: "WARNING",
|
|
1910
|
+
entityTable: table,
|
|
1911
|
+
previous: prevLabel,
|
|
1912
|
+
current: currLabel
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
const prevPk = prevEntity.fields.find((f) => f.isPrimaryKey)?.name;
|
|
1916
|
+
const currPk = currEntity.fields.find((f) => f.isPrimaryKey)?.name;
|
|
1917
|
+
if (prevPk !== currPk) {
|
|
1918
|
+
changes.push({
|
|
1919
|
+
category: "entity",
|
|
1920
|
+
kind: "entity_primary_key_changed",
|
|
1921
|
+
impact: "BREAKING",
|
|
1922
|
+
entityTable: table,
|
|
1923
|
+
previous: prevPk,
|
|
1924
|
+
current: currPk
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
changes.push(...this.compareFields(table, prevEntity.fields, currEntity.fields));
|
|
1928
|
+
}
|
|
1929
|
+
const prevRelMap = new Map(previous.relations.map((r) => [r.id, r]));
|
|
1930
|
+
const currRelMap = new Map(current.relations.map((r) => [r.id, r]));
|
|
1931
|
+
for (const [id, rel] of currRelMap) {
|
|
1932
|
+
if (!prevRelMap.has(id)) {
|
|
1933
|
+
changes.push({
|
|
1934
|
+
category: "relation",
|
|
1935
|
+
kind: "relation_added",
|
|
1936
|
+
impact: "SAFE",
|
|
1937
|
+
relationId: id,
|
|
1938
|
+
current: rel
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
for (const [id, rel] of prevRelMap) {
|
|
1943
|
+
if (!currRelMap.has(id)) {
|
|
1944
|
+
changes.push({
|
|
1945
|
+
category: "relation",
|
|
1946
|
+
kind: "relation_removed",
|
|
1947
|
+
impact: "BREAKING",
|
|
1948
|
+
relationId: id,
|
|
1949
|
+
previous: rel
|
|
1950
|
+
});
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
for (const [id, prevRel] of prevRelMap) {
|
|
1954
|
+
const currRel = currRelMap.get(id);
|
|
1955
|
+
if (!currRel) continue;
|
|
1956
|
+
if (prevRel.type !== currRel.type) {
|
|
1957
|
+
changes.push({
|
|
1958
|
+
category: "relation",
|
|
1959
|
+
kind: "relation_cardinality_changed",
|
|
1960
|
+
impact: "BREAKING",
|
|
1961
|
+
relationId: id,
|
|
1962
|
+
previous: prevRel.type,
|
|
1963
|
+
current: currRel.type
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
if (prevRel.from.field !== currRel.from.field || prevRel.to.field !== currRel.to.field) {
|
|
1967
|
+
changes.push({
|
|
1968
|
+
category: "relation",
|
|
1969
|
+
kind: "relation_fields_changed",
|
|
1970
|
+
impact: "BREAKING",
|
|
1971
|
+
relationId: id,
|
|
1972
|
+
previous: { from: prevRel.from.field, to: prevRel.to.field },
|
|
1973
|
+
current: { from: currRel.from.field, to: currRel.to.field }
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
const summary = {
|
|
1978
|
+
total: changes.length,
|
|
1979
|
+
safe: changes.filter((c) => c.impact === "SAFE").length,
|
|
1980
|
+
warnings: changes.filter((c) => c.impact === "WARNING").length,
|
|
1981
|
+
breaking: changes.filter((c) => c.impact === "BREAKING").length
|
|
1982
|
+
};
|
|
1983
|
+
return {
|
|
1984
|
+
changes,
|
|
1985
|
+
summary,
|
|
1986
|
+
hasBreaking: summary.breaking > 0,
|
|
1987
|
+
hasWarnings: summary.warnings > 0
|
|
1988
|
+
};
|
|
1989
|
+
}
|
|
1990
|
+
compareFields(entityTable, prevFields, currFields) {
|
|
1991
|
+
const changes = [];
|
|
1992
|
+
const prevMap = new Map(prevFields.map((f) => [f.name, f]));
|
|
1993
|
+
const currMap = new Map(currFields.map((f) => [f.name, f]));
|
|
1994
|
+
for (const [name, field] of currMap) {
|
|
1995
|
+
if (!prevMap.has(name)) {
|
|
1996
|
+
changes.push({
|
|
1997
|
+
category: "field",
|
|
1998
|
+
kind: "field_added",
|
|
1999
|
+
impact: "SAFE",
|
|
2000
|
+
entityTable,
|
|
2001
|
+
fieldName: name,
|
|
2002
|
+
current: field
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
for (const [name, field] of prevMap) {
|
|
2007
|
+
if (!currMap.has(name)) {
|
|
2008
|
+
changes.push({
|
|
2009
|
+
category: "field",
|
|
2010
|
+
kind: "field_removed",
|
|
2011
|
+
impact: "BREAKING",
|
|
2012
|
+
entityTable,
|
|
2013
|
+
fieldName: name,
|
|
2014
|
+
previous: field
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
for (const [name, prevField] of prevMap) {
|
|
2019
|
+
const currField = currMap.get(name);
|
|
2020
|
+
if (!currField) continue;
|
|
2021
|
+
if (prevField.type !== currField.type) {
|
|
2022
|
+
changes.push({
|
|
2023
|
+
category: "field",
|
|
2024
|
+
kind: "field_type_changed",
|
|
2025
|
+
impact: "BREAKING",
|
|
2026
|
+
entityTable,
|
|
2027
|
+
fieldName: name,
|
|
2028
|
+
previous: prevField.type,
|
|
2029
|
+
current: currField.type
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
if (prevField.nullable !== currField.nullable) {
|
|
2033
|
+
const impact = !prevField.nullable && currField.nullable ? "SAFE" : "BREAKING";
|
|
2034
|
+
changes.push({
|
|
2035
|
+
category: "field",
|
|
2036
|
+
kind: "field_nullable_changed",
|
|
2037
|
+
impact,
|
|
2038
|
+
entityTable,
|
|
2039
|
+
fieldName: name,
|
|
2040
|
+
previous: prevField.nullable,
|
|
2041
|
+
current: currField.nullable
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
const wasIndexed = prevField.isIndexed ?? false;
|
|
2045
|
+
const isIndexed = currField.isIndexed ?? false;
|
|
2046
|
+
if (wasIndexed !== isIndexed) {
|
|
2047
|
+
if (isIndexed) {
|
|
2048
|
+
changes.push({
|
|
2049
|
+
category: "field",
|
|
2050
|
+
kind: "field_index_added",
|
|
2051
|
+
impact: "SAFE",
|
|
2052
|
+
entityTable,
|
|
2053
|
+
fieldName: name,
|
|
2054
|
+
previous: false,
|
|
2055
|
+
current: true
|
|
2056
|
+
});
|
|
2057
|
+
} else {
|
|
2058
|
+
changes.push({
|
|
2059
|
+
category: "field",
|
|
2060
|
+
kind: "field_index_removed",
|
|
2061
|
+
impact: "WARNING",
|
|
2062
|
+
entityTable,
|
|
2063
|
+
fieldName: name,
|
|
2064
|
+
previous: true,
|
|
2065
|
+
current: false
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
const prevMeta = {
|
|
2070
|
+
candidateForSearch: prevField.candidateForSearch ?? false,
|
|
2071
|
+
candidateForSoftDelete: prevField.candidateForSoftDelete ?? false
|
|
2072
|
+
};
|
|
2073
|
+
const currMeta = {
|
|
2074
|
+
candidateForSearch: currField.candidateForSearch ?? false,
|
|
2075
|
+
candidateForSoftDelete: currField.candidateForSoftDelete ?? false
|
|
2076
|
+
};
|
|
2077
|
+
if (prevMeta.candidateForSearch !== currMeta.candidateForSearch || prevMeta.candidateForSoftDelete !== currMeta.candidateForSoftDelete) {
|
|
2078
|
+
changes.push({
|
|
2079
|
+
category: "field",
|
|
2080
|
+
kind: "field_metadata_changed",
|
|
2081
|
+
impact: "WARNING",
|
|
2082
|
+
entityTable,
|
|
2083
|
+
fieldName: name,
|
|
2084
|
+
previous: prevMeta,
|
|
2085
|
+
current: currMeta
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
return changes;
|
|
2090
|
+
}
|
|
2091
|
+
};
|
|
2092
|
+
|
|
2093
|
+
// src/introspection-report/ReportGenerator.ts
|
|
2094
|
+
var ReportGenerator = class {
|
|
2095
|
+
generate(result, diff) {
|
|
2096
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2097
|
+
const hasDiff = diff !== void 0;
|
|
2098
|
+
const stats = {
|
|
2099
|
+
entitiesDiscovered: result.entities.length,
|
|
2100
|
+
relationsDiscovered: result.relations.length,
|
|
2101
|
+
totalChanges: diff?.summary.total ?? 0,
|
|
2102
|
+
safeChanges: diff?.summary.safe ?? 0,
|
|
2103
|
+
warnings: diff?.summary.warnings ?? 0,
|
|
2104
|
+
breakingChanges: diff?.summary.breaking ?? 0
|
|
2105
|
+
};
|
|
2106
|
+
const changes = hasDiff ? diff.changes.map((c) => this.describeChange(c)) : [];
|
|
2107
|
+
const text = this.generateText(stats, changes, hasDiff);
|
|
2108
|
+
return { completedAt, stats, changes, hasDiff, text };
|
|
2109
|
+
}
|
|
2110
|
+
describeChange(change) {
|
|
2111
|
+
switch (change.category) {
|
|
2112
|
+
case "entity": {
|
|
2113
|
+
switch (change.kind) {
|
|
2114
|
+
case "entity_added":
|
|
2115
|
+
return {
|
|
2116
|
+
impact: "SAFE",
|
|
2117
|
+
description: `Entity "${change.entityTable}" added`
|
|
2118
|
+
};
|
|
2119
|
+
case "entity_removed":
|
|
2120
|
+
return {
|
|
2121
|
+
impact: "BREAKING",
|
|
2122
|
+
description: `Entity "${change.entityTable}" removed`,
|
|
2123
|
+
recommendation: `Ensure all consumers of "${change.entityTable}" are updated before deploying.`
|
|
2124
|
+
};
|
|
2125
|
+
case "entity_label_changed":
|
|
2126
|
+
return {
|
|
2127
|
+
impact: "WARNING",
|
|
2128
|
+
description: `Entity "${change.entityTable}" label changed: "${change.previous}" \u2192 "${change.current}"`,
|
|
2129
|
+
recommendation: "Update UI labels and documentation to reflect the new entity name."
|
|
2130
|
+
};
|
|
2131
|
+
case "entity_primary_key_changed":
|
|
2132
|
+
return {
|
|
2133
|
+
impact: "BREAKING",
|
|
2134
|
+
description: `Entity "${change.entityTable}" primary key changed: "${change.previous}" \u2192 "${change.current}"`,
|
|
2135
|
+
recommendation: "This is a destructive change. Review all queries and foreign key references before deploying."
|
|
2136
|
+
};
|
|
2137
|
+
}
|
|
2138
|
+
break;
|
|
2139
|
+
}
|
|
2140
|
+
case "field": {
|
|
2141
|
+
switch (change.kind) {
|
|
2142
|
+
case "field_added":
|
|
2143
|
+
return {
|
|
2144
|
+
impact: "SAFE",
|
|
2145
|
+
description: `Field "${change.entityTable}.${change.fieldName}" added`
|
|
2146
|
+
};
|
|
2147
|
+
case "field_removed":
|
|
2148
|
+
return {
|
|
2149
|
+
impact: "BREAKING",
|
|
2150
|
+
description: `Field "${change.entityTable}.${change.fieldName}" removed`,
|
|
2151
|
+
recommendation: `Remove all references to "${change.fieldName}" in "${change.entityTable}" before deploying.`
|
|
2152
|
+
};
|
|
2153
|
+
case "field_type_changed":
|
|
2154
|
+
return {
|
|
2155
|
+
impact: "BREAKING",
|
|
2156
|
+
description: `Field "${change.entityTable}.${change.fieldName}" changed type: ${change.previous} \u2192 ${change.current}`,
|
|
2157
|
+
recommendation: "Verify that existing data can be migrated to the new type and that all consumers handle the new type."
|
|
2158
|
+
};
|
|
2159
|
+
case "field_nullable_changed":
|
|
2160
|
+
if (!change.previous && change.current) {
|
|
2161
|
+
return {
|
|
2162
|
+
impact: "SAFE",
|
|
2163
|
+
description: `Field "${change.entityTable}.${change.fieldName}" is now optional (was required)`
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
2166
|
+
return {
|
|
2167
|
+
impact: "BREAKING",
|
|
2168
|
+
description: `Field "${change.entityTable}.${change.fieldName}" is now required (was optional)`,
|
|
2169
|
+
recommendation: "Ensure existing rows have values for this field before deploying the migration."
|
|
2170
|
+
};
|
|
2171
|
+
case "field_index_added":
|
|
2172
|
+
return {
|
|
2173
|
+
impact: "SAFE",
|
|
2174
|
+
description: `Index added to field "${change.entityTable}.${change.fieldName}"`
|
|
2175
|
+
};
|
|
2176
|
+
case "field_index_removed":
|
|
2177
|
+
return {
|
|
2178
|
+
impact: "WARNING",
|
|
2179
|
+
description: `Index removed from field "${change.entityTable}.${change.fieldName}"`,
|
|
2180
|
+
recommendation: "Removing indexes may degrade query performance. Review affected queries."
|
|
2181
|
+
};
|
|
2182
|
+
case "field_metadata_changed":
|
|
2183
|
+
return {
|
|
2184
|
+
impact: "WARNING",
|
|
2185
|
+
description: `Derived metadata changed for field "${change.entityTable}.${change.fieldName}"`
|
|
2186
|
+
};
|
|
2187
|
+
}
|
|
2188
|
+
break;
|
|
2189
|
+
}
|
|
2190
|
+
case "relation": {
|
|
2191
|
+
switch (change.kind) {
|
|
2192
|
+
case "relation_added":
|
|
2193
|
+
return {
|
|
2194
|
+
impact: "SAFE",
|
|
2195
|
+
description: `Relation "${change.relationId}" added`
|
|
2196
|
+
};
|
|
2197
|
+
case "relation_removed":
|
|
2198
|
+
return {
|
|
2199
|
+
impact: "BREAKING",
|
|
2200
|
+
description: `Relation "${change.relationId}" removed`,
|
|
2201
|
+
recommendation: `Update all code that references relation "${change.relationId}".`
|
|
2202
|
+
};
|
|
2203
|
+
case "relation_cardinality_changed":
|
|
2204
|
+
return {
|
|
2205
|
+
impact: "BREAKING",
|
|
2206
|
+
description: `Relation "${change.relationId}" cardinality changed: ${change.previous} \u2192 ${change.current}`,
|
|
2207
|
+
recommendation: "Verify data integrity and update all query logic that depends on this relation."
|
|
2208
|
+
};
|
|
2209
|
+
case "relation_fields_changed": {
|
|
2210
|
+
const prev = change.previous;
|
|
2211
|
+
const curr = change.current;
|
|
2212
|
+
return {
|
|
2213
|
+
impact: "BREAKING",
|
|
2214
|
+
description: `Relation "${change.relationId}" participating fields changed (from: ${prev?.from} \u2192 ${curr?.from}, to: ${prev?.to} \u2192 ${curr?.to})`,
|
|
2215
|
+
recommendation: "Review all JOIN logic and foreign key constraints for this relation."
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
break;
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
return { impact: "WARNING", description: "Unknown change detected" };
|
|
2223
|
+
}
|
|
2224
|
+
generateText(stats, changes, hasDiff) {
|
|
2225
|
+
const lines = [];
|
|
2226
|
+
lines.push("Introspection completed");
|
|
2227
|
+
lines.push("");
|
|
2228
|
+
lines.push(`Entities discovered: ${stats.entitiesDiscovered}`);
|
|
2229
|
+
lines.push(`Relations discovered: ${stats.relationsDiscovered}`);
|
|
2230
|
+
if (!hasDiff) {
|
|
2231
|
+
lines.push("");
|
|
2232
|
+
lines.push("No previous snapshot available for comparison.");
|
|
2233
|
+
return lines.join("\n");
|
|
2234
|
+
}
|
|
2235
|
+
if (stats.totalChanges === 0) {
|
|
2236
|
+
lines.push("");
|
|
2237
|
+
lines.push("No changes detected since last snapshot.");
|
|
2238
|
+
return lines.join("\n");
|
|
2239
|
+
}
|
|
2240
|
+
lines.push("");
|
|
2241
|
+
lines.push(`Changes detected: ${stats.totalChanges}`);
|
|
2242
|
+
const safeChanges = changes.filter((c) => c.impact === "SAFE");
|
|
2243
|
+
const warnings = changes.filter((c) => c.impact === "WARNING");
|
|
2244
|
+
const breaking = changes.filter((c) => c.impact === "BREAKING");
|
|
2245
|
+
if (safeChanges.length > 0) {
|
|
2246
|
+
lines.push("");
|
|
2247
|
+
lines.push("SAFE:");
|
|
2248
|
+
for (const c of safeChanges) {
|
|
2249
|
+
lines.push(`- ${c.description}`);
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
if (warnings.length > 0) {
|
|
2253
|
+
lines.push("");
|
|
2254
|
+
lines.push("WARNINGS:");
|
|
2255
|
+
for (const c of warnings) {
|
|
2256
|
+
lines.push(`- ${c.description}`);
|
|
2257
|
+
if (c.recommendation) lines.push(` \u2192 ${c.recommendation}`);
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
if (breaking.length > 0) {
|
|
2261
|
+
lines.push("");
|
|
2262
|
+
lines.push("BREAKING:");
|
|
2263
|
+
for (const c of breaking) {
|
|
2264
|
+
lines.push(`- ${c.description}`);
|
|
2265
|
+
if (c.recommendation) lines.push(` \u2192 ${c.recommendation}`);
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
return lines.join("\n");
|
|
2269
|
+
}
|
|
2270
|
+
};
|
|
2271
|
+
|
|
2272
|
+
// src/snapshot/InMemorySnapshotRepository.ts
|
|
2273
|
+
var InMemorySnapshotRepository = class {
|
|
2274
|
+
store = /* @__PURE__ */ new Map();
|
|
2275
|
+
async save(snapshot) {
|
|
2276
|
+
this.store.set(snapshot.id, snapshot);
|
|
2277
|
+
}
|
|
2278
|
+
async load(id) {
|
|
2279
|
+
return this.store.get(id) ?? null;
|
|
2280
|
+
}
|
|
2281
|
+
};
|
|
2282
|
+
|
|
2283
|
+
// src/introspection-runtime/IntrospectionRuntime.ts
|
|
2284
|
+
var _snapshotCounter = 0;
|
|
2285
|
+
function generateSnapshotId() {
|
|
2286
|
+
return `snapshot-${Date.now()}-${++_snapshotCounter}`;
|
|
2287
|
+
}
|
|
2288
|
+
var IntrospectionRuntime = class {
|
|
2289
|
+
constructor(snapshotRepository) {
|
|
2290
|
+
this.snapshotRepository = snapshotRepository;
|
|
2291
|
+
}
|
|
2292
|
+
snapshotRepository;
|
|
2293
|
+
lastProvider;
|
|
2294
|
+
async run(options) {
|
|
2295
|
+
this.lastProvider = options.provider;
|
|
2296
|
+
const introspectionResult = await options.provider.introspect();
|
|
2297
|
+
const snapshot = {
|
|
2298
|
+
id: generateSnapshotId(),
|
|
2299
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2300
|
+
result: introspectionResult
|
|
2301
|
+
};
|
|
2302
|
+
let diff;
|
|
2303
|
+
if (options.snapshotId && this.snapshotRepository) {
|
|
2304
|
+
const previous = await this.snapshotRepository.load(options.snapshotId);
|
|
2305
|
+
if (previous) {
|
|
2306
|
+
const engine = new DiffEngine();
|
|
2307
|
+
diff = engine.compare(previous.result, introspectionResult);
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
const reportGenerator = new ReportGenerator();
|
|
2311
|
+
const report = reportGenerator.generate(introspectionResult, diff);
|
|
2312
|
+
const shouldSave = options.saveSnapshot !== false && this.snapshotRepository !== void 0;
|
|
2313
|
+
if (shouldSave) {
|
|
2314
|
+
await this.snapshotRepository.save(snapshot);
|
|
2315
|
+
}
|
|
2316
|
+
const overrides = options.overrides ?? { entities: [], relations: [] };
|
|
2317
|
+
const datasources = options.datasources ?? {};
|
|
2318
|
+
const defaultDatasource = options.defaultDatasource ?? "";
|
|
2319
|
+
const config = mergeIntrospectionWithOverrides({
|
|
2320
|
+
introspection: introspectionResult,
|
|
2321
|
+
overrides,
|
|
2322
|
+
datasources,
|
|
2323
|
+
defaultDatasource,
|
|
2324
|
+
strategy: options.mergeStrategy ?? "extend",
|
|
2325
|
+
operations: options.operations,
|
|
2326
|
+
audit: options.audit,
|
|
2327
|
+
logger: options.logger
|
|
2328
|
+
});
|
|
2329
|
+
if (options.policies) {
|
|
2330
|
+
config.policies = options.policies;
|
|
2331
|
+
}
|
|
2332
|
+
const metadataEngine = new MetadataEngine();
|
|
2333
|
+
const metadata = metadataEngine.normalize(config);
|
|
2334
|
+
return { metadata, diff, report, snapshot };
|
|
2335
|
+
}
|
|
2336
|
+
async saveSnapshot(snapshot) {
|
|
2337
|
+
if (!this.snapshotRepository) {
|
|
2338
|
+
throw new Error(
|
|
2339
|
+
"IntrospectionRuntime: no snapshotRepository configured. Pass one in the constructor."
|
|
2340
|
+
);
|
|
2341
|
+
}
|
|
2342
|
+
await this.snapshotRepository.save(snapshot);
|
|
2343
|
+
}
|
|
2344
|
+
async loadSnapshot(id) {
|
|
2345
|
+
if (!this.snapshotRepository) return null;
|
|
2346
|
+
return this.snapshotRepository.load(id);
|
|
2347
|
+
}
|
|
2348
|
+
async compareWithSnapshot(snapshotId, provider) {
|
|
2349
|
+
if (!this.snapshotRepository) return null;
|
|
2350
|
+
const resolvedProvider = provider ?? this.lastProvider;
|
|
2351
|
+
if (!resolvedProvider) {
|
|
2352
|
+
throw new Error(
|
|
2353
|
+
"IntrospectionRuntime.compareWithSnapshot: a provider must be supplied or run() must have been called first."
|
|
2354
|
+
);
|
|
2355
|
+
}
|
|
2356
|
+
const previous = await this.snapshotRepository.load(snapshotId);
|
|
2357
|
+
if (!previous) return null;
|
|
2358
|
+
const current = await resolvedProvider.introspect();
|
|
2359
|
+
const engine = new DiffEngine();
|
|
2360
|
+
return engine.compare(previous.result, current);
|
|
2361
|
+
}
|
|
2362
|
+
};
|
|
2363
|
+
|
|
2364
|
+
// src/correlation/CorrelationId.ts
|
|
2365
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
2366
|
+
function generateCorrelationId() {
|
|
2367
|
+
return randomUUID4();
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
// src/risk/MetadataRiskClassifier.ts
|
|
2371
|
+
var MetadataRiskClassifier = class {
|
|
2372
|
+
constructor(operationRiskMap = {}, defaultRisk = "LOW") {
|
|
2373
|
+
this.operationRiskMap = operationRiskMap;
|
|
2374
|
+
this.defaultRisk = defaultRisk;
|
|
2375
|
+
}
|
|
2376
|
+
operationRiskMap;
|
|
2377
|
+
defaultRisk;
|
|
2378
|
+
classify(input) {
|
|
2379
|
+
const fromMetadata = input.metadata?.["riskLevel"];
|
|
2380
|
+
if (isOperationalRisk(fromMetadata)) return fromMetadata;
|
|
2381
|
+
return this.operationRiskMap[input.operation] ?? this.defaultRisk;
|
|
2382
|
+
}
|
|
2383
|
+
};
|
|
2384
|
+
var RISK_LEVELS = ["LOW", "MEDIUM", "HIGH", "CRITICAL"];
|
|
2385
|
+
function isOperationalRisk(value) {
|
|
2386
|
+
return typeof value === "string" && RISK_LEVELS.includes(value);
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
// src/authorization/ContextualAuthorizationEngine.ts
|
|
2390
|
+
var ContextualAuthorizationEngine = class {
|
|
2391
|
+
constructor(policy, criticalOperations = /* @__PURE__ */ new Set()) {
|
|
2392
|
+
this.criticalOperations = criticalOperations;
|
|
2393
|
+
this.rbac = new RbacEngine(policy);
|
|
2394
|
+
}
|
|
2395
|
+
criticalOperations;
|
|
2396
|
+
rbac;
|
|
2397
|
+
async evaluate(context) {
|
|
2398
|
+
if (!this.rbac.can(context.actor, context.operation)) {
|
|
2399
|
+
return { decision: "DENY", reason: `Actor lacks permission for operation: ${context.operation}` };
|
|
2400
|
+
}
|
|
2401
|
+
const risk = context.riskLevel ?? "LOW";
|
|
2402
|
+
if (risk === "CRITICAL" || this.criticalOperations.has(context.operation)) {
|
|
2403
|
+
return { decision: "REQUIRES_CONFIRMATION", reason: `Operation requires confirmation due to risk level: ${risk}` };
|
|
2404
|
+
}
|
|
2405
|
+
return { decision: "ALLOW" };
|
|
2406
|
+
}
|
|
2407
|
+
};
|
|
2408
|
+
|
|
2409
|
+
// src/policy/PolicyEngine.ts
|
|
2410
|
+
var DECISION_PRIORITY = {
|
|
2411
|
+
ALLOW: 0,
|
|
2412
|
+
REQUIRES_CONFIRMATION: 1,
|
|
2413
|
+
DENY: 2
|
|
2414
|
+
};
|
|
2415
|
+
var PolicyEngine = class {
|
|
2416
|
+
constructor(provider) {
|
|
2417
|
+
this.provider = provider;
|
|
2418
|
+
}
|
|
2419
|
+
provider;
|
|
2420
|
+
evaluate(context) {
|
|
2421
|
+
const violations = [];
|
|
2422
|
+
let decision = "ALLOW";
|
|
2423
|
+
let reason;
|
|
2424
|
+
for (const rule of this.provider.rules()) {
|
|
2425
|
+
const result = rule.evaluate(context);
|
|
2426
|
+
if (result === null) continue;
|
|
2427
|
+
if (DECISION_PRIORITY[result.decision] > DECISION_PRIORITY[decision]) {
|
|
2428
|
+
decision = result.decision;
|
|
2429
|
+
reason = result.reason;
|
|
2430
|
+
}
|
|
2431
|
+
if (result.decision === "DENY" || result.decision === "REQUIRES_CONFIRMATION") {
|
|
2432
|
+
violations.push({
|
|
2433
|
+
ruleId: rule.id,
|
|
2434
|
+
reason: result.reason,
|
|
2435
|
+
context,
|
|
2436
|
+
occurredAt: /* @__PURE__ */ new Date()
|
|
2437
|
+
});
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
return { decision, violations, reason };
|
|
2441
|
+
}
|
|
2442
|
+
};
|
|
2443
|
+
|
|
2444
|
+
// src/policy/InMemoryPolicyProvider.ts
|
|
2445
|
+
var InMemoryPolicyProvider = class {
|
|
2446
|
+
_rules;
|
|
2447
|
+
constructor(rules = []) {
|
|
2448
|
+
this._rules = rules;
|
|
2449
|
+
}
|
|
2450
|
+
rules() {
|
|
2451
|
+
return this._rules;
|
|
2452
|
+
}
|
|
2453
|
+
};
|
|
2454
|
+
|
|
2455
|
+
// src/confirmation/ConfirmationEngine.ts
|
|
2456
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
2457
|
+
var ConfirmationEngine = class {
|
|
2458
|
+
constructor(repository) {
|
|
2459
|
+
this.repository = repository;
|
|
2460
|
+
}
|
|
2461
|
+
repository;
|
|
2462
|
+
async request(input) {
|
|
2463
|
+
const requiredApprovals = input.requiredApprovals ?? 1;
|
|
2464
|
+
const request = {
|
|
2465
|
+
id: randomUUID5(),
|
|
2466
|
+
operation: input.operation,
|
|
2467
|
+
actor: input.actor,
|
|
2468
|
+
resource: input.resource,
|
|
2469
|
+
status: requiredApprovals > 1 ? "AWAITING_CONFIRMATION" : "REQUESTED",
|
|
2470
|
+
requiredApprovals,
|
|
2471
|
+
approvals: [],
|
|
2472
|
+
rejections: [],
|
|
2473
|
+
requestedAt: /* @__PURE__ */ new Date(),
|
|
2474
|
+
expiresAt: input.expiresAt,
|
|
2475
|
+
metadata: input.metadata,
|
|
2476
|
+
correlationId: input.correlationId
|
|
2477
|
+
};
|
|
2478
|
+
await this.repository.save(request);
|
|
2479
|
+
return request;
|
|
2480
|
+
}
|
|
2481
|
+
async approve(requestId, approver, comment) {
|
|
2482
|
+
const request = await this.findOrThrow(requestId);
|
|
2483
|
+
this.assertActive(request);
|
|
2484
|
+
const updated = {
|
|
2485
|
+
...request,
|
|
2486
|
+
approvals: [...request.approvals, { approver, approvedAt: /* @__PURE__ */ new Date(), comment }]
|
|
2487
|
+
};
|
|
2488
|
+
const fullyApproved = updated.approvals.length >= updated.requiredApprovals;
|
|
2489
|
+
updated.status = fullyApproved ? "APPROVED" : "AWAITING_CONFIRMATION";
|
|
2490
|
+
await this.repository.save(updated);
|
|
2491
|
+
return updated;
|
|
2492
|
+
}
|
|
2493
|
+
async reject(requestId, approver, comment) {
|
|
2494
|
+
const request = await this.findOrThrow(requestId);
|
|
2495
|
+
this.assertActive(request);
|
|
2496
|
+
const updated = {
|
|
2497
|
+
...request,
|
|
2498
|
+
status: "REJECTED",
|
|
2499
|
+
rejections: [...request.rejections, { approver, approvedAt: /* @__PURE__ */ new Date(), comment }]
|
|
2500
|
+
};
|
|
2501
|
+
await this.repository.save(updated);
|
|
2502
|
+
return updated;
|
|
2503
|
+
}
|
|
2504
|
+
async execute(requestId) {
|
|
2505
|
+
const request = await this.findOrThrow(requestId);
|
|
2506
|
+
if (request.status !== "APPROVED") {
|
|
2507
|
+
throw new MaestroError(
|
|
2508
|
+
"VALIDATION_ERROR" /* VALIDATION_ERROR */,
|
|
2509
|
+
`Cannot execute confirmation request in status: ${request.status}`
|
|
2510
|
+
);
|
|
2511
|
+
}
|
|
2512
|
+
const updated = {
|
|
2513
|
+
...request,
|
|
2514
|
+
status: "EXECUTED",
|
|
2515
|
+
executedAt: /* @__PURE__ */ new Date()
|
|
2516
|
+
};
|
|
2517
|
+
await this.repository.save(updated);
|
|
2518
|
+
return updated;
|
|
2519
|
+
}
|
|
2520
|
+
async getPending() {
|
|
2521
|
+
return this.repository.listPending();
|
|
2522
|
+
}
|
|
2523
|
+
async findOrThrow(requestId) {
|
|
2524
|
+
const request = await this.repository.findById(requestId);
|
|
2525
|
+
if (!request) {
|
|
2526
|
+
throw new MaestroError(
|
|
2527
|
+
"NOT_FOUND" /* NOT_FOUND */,
|
|
2528
|
+
`Confirmation request not found: ${requestId}`
|
|
2529
|
+
);
|
|
2530
|
+
}
|
|
2531
|
+
return request;
|
|
2532
|
+
}
|
|
2533
|
+
assertActive(request) {
|
|
2534
|
+
const terminal = ["APPROVED", "REJECTED", "EXECUTED", "EXPIRED"];
|
|
2535
|
+
if (terminal.includes(request.status)) {
|
|
2536
|
+
throw new MaestroError(
|
|
2537
|
+
"VALIDATION_ERROR" /* VALIDATION_ERROR */,
|
|
2538
|
+
`Confirmation request is already in terminal status: ${request.status}`
|
|
2539
|
+
);
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
};
|
|
2543
|
+
|
|
2544
|
+
// src/confirmation/InMemoryConfirmationRepository.ts
|
|
2545
|
+
var InMemoryConfirmationRepository = class {
|
|
2546
|
+
store = /* @__PURE__ */ new Map();
|
|
2547
|
+
async save(request) {
|
|
2548
|
+
this.store.set(request.id, request);
|
|
2549
|
+
}
|
|
2550
|
+
async findById(id) {
|
|
2551
|
+
return this.store.get(id);
|
|
2552
|
+
}
|
|
2553
|
+
async listPending() {
|
|
2554
|
+
return Array.from(this.store.values()).filter(
|
|
2555
|
+
(r) => r.status === "REQUESTED" || r.status === "AWAITING_CONFIRMATION"
|
|
2556
|
+
);
|
|
2557
|
+
}
|
|
2558
|
+
};
|
|
2559
|
+
|
|
2560
|
+
// src/governance/GovernanceEventType.ts
|
|
2561
|
+
var GOVERNANCE_EVENT_TYPES = {
|
|
2562
|
+
OPERATION_EXECUTED: "governance.operation.executed",
|
|
2563
|
+
AUTHORIZATION_DENIED: "governance.authorization.denied",
|
|
2564
|
+
POLICY_TRIGGERED: "governance.policy.triggered",
|
|
2565
|
+
CONFIRMATION_REQUESTED: "governance.confirmation.requested",
|
|
2566
|
+
CONFIRMATION_APPROVED: "governance.confirmation.approved",
|
|
2567
|
+
CONFIRMATION_REJECTED: "governance.confirmation.rejected",
|
|
2568
|
+
AUDIT_RECORDED: "governance.audit.recorded"
|
|
2569
|
+
};
|
|
2570
|
+
|
|
2571
|
+
// src/governance/InMemoryGovernanceEventBus.ts
|
|
2572
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
2573
|
+
var InMemoryGovernanceEventBus = class extends InMemoryEventBus {
|
|
2574
|
+
async publishGovernance(type, payload, correlationId) {
|
|
2575
|
+
const event = {
|
|
2576
|
+
id: randomUUID6(),
|
|
2577
|
+
type,
|
|
2578
|
+
occurredAt: /* @__PURE__ */ new Date(),
|
|
2579
|
+
payload,
|
|
2580
|
+
correlationId
|
|
2581
|
+
};
|
|
2582
|
+
await this.publish(event);
|
|
2583
|
+
return event;
|
|
2584
|
+
}
|
|
2585
|
+
};
|
|
2586
|
+
|
|
2587
|
+
// src/governance/DefaultGovernanceApi.ts
|
|
2588
|
+
var DefaultGovernanceApi = class {
|
|
2589
|
+
constructor(auditRepository, confirmationRepository, violationLog = []) {
|
|
2590
|
+
this.auditRepository = auditRepository;
|
|
2591
|
+
this.confirmationRepository = confirmationRepository;
|
|
2592
|
+
this.violationLog = violationLog;
|
|
2593
|
+
}
|
|
2594
|
+
auditRepository;
|
|
2595
|
+
confirmationRepository;
|
|
2596
|
+
violationLog;
|
|
2597
|
+
async getAuditTimeline(filter) {
|
|
2598
|
+
return this.auditRepository.list(filter);
|
|
2599
|
+
}
|
|
2600
|
+
async getCorrelationTrace(correlationId) {
|
|
2601
|
+
return this.auditRepository.list({ correlationId });
|
|
2602
|
+
}
|
|
2603
|
+
async getPendingConfirmations() {
|
|
2604
|
+
return this.confirmationRepository.listPending();
|
|
2605
|
+
}
|
|
2606
|
+
async getPolicyViolations(filter) {
|
|
2607
|
+
let violations = [...this.violationLog];
|
|
2608
|
+
if (filter?.ruleId !== void 0) {
|
|
2609
|
+
violations = violations.filter((v) => v.ruleId === filter.ruleId);
|
|
2610
|
+
}
|
|
2611
|
+
if (filter?.correlationId !== void 0) {
|
|
2612
|
+
violations = violations.filter((v) => v.context.correlationId === filter.correlationId);
|
|
2613
|
+
}
|
|
2614
|
+
if (filter?.from !== void 0) {
|
|
2615
|
+
const from = filter.from;
|
|
2616
|
+
violations = violations.filter((v) => v.occurredAt >= from);
|
|
2617
|
+
}
|
|
2618
|
+
if (filter?.to !== void 0) {
|
|
2619
|
+
const to = filter.to;
|
|
2620
|
+
violations = violations.filter((v) => v.occurredAt <= to);
|
|
2621
|
+
}
|
|
2622
|
+
return violations;
|
|
2623
|
+
}
|
|
2624
|
+
};
|
|
235
2625
|
export {
|
|
236
2626
|
AuditRecorder,
|
|
2627
|
+
ConfirmationEngine,
|
|
237
2628
|
ConsoleLogger,
|
|
2629
|
+
ConsoleStructuredLogger,
|
|
2630
|
+
ContextualAuthorizationEngine,
|
|
2631
|
+
CsvExportProvider,
|
|
2632
|
+
DEFAULT_CAPABILITIES,
|
|
2633
|
+
DatasourceRegistry,
|
|
2634
|
+
DefaultGovernanceApi,
|
|
2635
|
+
DiffEngine,
|
|
238
2636
|
ErrorCode,
|
|
2637
|
+
GOVERNANCE_EVENT_TYPES,
|
|
239
2638
|
InMemoryAuditRepository,
|
|
240
2639
|
InMemoryConfigProvider,
|
|
2640
|
+
InMemoryConfirmationRepository,
|
|
2641
|
+
InMemoryDatasourceProvider,
|
|
241
2642
|
InMemoryEventBus,
|
|
242
2643
|
InMemoryFeatureFlagProvider,
|
|
2644
|
+
InMemoryGovernanceEventBus,
|
|
2645
|
+
InMemoryPolicyProvider,
|
|
2646
|
+
InMemorySnapshotRepository,
|
|
2647
|
+
IntrospectionRuntime,
|
|
2648
|
+
MaestroEngine,
|
|
243
2649
|
MaestroError,
|
|
244
|
-
|
|
2650
|
+
MetadataEngine,
|
|
2651
|
+
MetadataRiskClassifier,
|
|
2652
|
+
OperationRegistry,
|
|
2653
|
+
PolicyEngine,
|
|
2654
|
+
RbacEngine,
|
|
2655
|
+
ReportGenerator,
|
|
2656
|
+
createMaestro,
|
|
2657
|
+
createMaestroFromIntrospection,
|
|
2658
|
+
createMaestroHttpHandlers,
|
|
2659
|
+
detectDisplayField,
|
|
2660
|
+
generateAllConfigs,
|
|
2661
|
+
generateCorrelationId,
|
|
2662
|
+
generateEntityConfig,
|
|
2663
|
+
generateRelationConfig,
|
|
2664
|
+
humanizeFieldName,
|
|
2665
|
+
inferFieldType,
|
|
2666
|
+
isSearchCandidate,
|
|
2667
|
+
isSoftDeleteCandidate,
|
|
2668
|
+
isTimestampField,
|
|
2669
|
+
loadMaestroConfig,
|
|
2670
|
+
mergeIntrospectionWithOverrides,
|
|
2671
|
+
parseQueryInput,
|
|
2672
|
+
tableNameToEntityId,
|
|
2673
|
+
tableNameToLabel,
|
|
2674
|
+
validateMaestroConfig
|
|
245
2675
|
};
|
|
246
2676
|
//# sourceMappingURL=index.js.map
|