@dudousxd/nestjs-catalog 0.4.0 → 0.5.0
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/dist/catalog.environment.js +180 -102
- package/dist/catalog.pipeline.js +105 -64
- package/dist/catalog.query-cache.js +0 -0
- package/dist/catalog.registry.js +17 -4
- package/dist/catalog.workspace.d.ts +16 -0
- package/package.json +1 -1
|
@@ -324,12 +324,46 @@ exports.PROMOTION_AUDIT_EVENT = 'promotion.applied';
|
|
|
324
324
|
*/
|
|
325
325
|
function planPromotion(input) {
|
|
326
326
|
const { from, to, source, target } = input;
|
|
327
|
-
const changes = [];
|
|
328
|
-
const blockers = [];
|
|
329
|
-
const withheld = [];
|
|
330
327
|
const selected = (kind, id) => input.select === undefined || input.select.includes(`${kind}:${id}`);
|
|
331
|
-
//
|
|
328
|
+
// The order changes are listed in is the order they must be applied. Workflows
|
|
329
|
+
// come before connectors deliberately: a connector arriving before the graph it
|
|
330
|
+
// runs would point at nothing for as long as the apply takes.
|
|
331
|
+
const changes = [
|
|
332
|
+
...planObjectTypes(source, target, selected),
|
|
333
|
+
...planTransforms(source, target, selected),
|
|
334
|
+
...planWorkflows(source, target, to, selected),
|
|
335
|
+
];
|
|
336
|
+
// Connectors last, and given what came before: whether a connector's transform
|
|
337
|
+
// or workflow is acceptable depends on what this same promotion is carrying.
|
|
338
|
+
const connectors = planConnectors({ source, target, from, to, selected, earlier: changes });
|
|
339
|
+
changes.push(...connectors.changes);
|
|
340
|
+
const createdAt = (input.now?.() ?? new Date()).toISOString();
|
|
341
|
+
return {
|
|
342
|
+
from,
|
|
343
|
+
to,
|
|
344
|
+
createdAt,
|
|
345
|
+
changes,
|
|
346
|
+
blockers: connectors.blockers,
|
|
347
|
+
withheld: connectors.withheld,
|
|
348
|
+
fingerprint: fingerprintOf(from, to, changes, connectors.blockers),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* What a promotion does to one thing: creates it, updates it, or nothing.
|
|
353
|
+
*
|
|
354
|
+
* One definition rather than four copies of the same nested ternary, because
|
|
355
|
+
* "unchanged" is what the summary counts and the fingerprint filters on, and
|
|
356
|
+
* the four kinds disagreeing about it would be invisible until it mattered.
|
|
357
|
+
*/
|
|
358
|
+
function actionFor(existing, fields) {
|
|
359
|
+
if (!existing)
|
|
360
|
+
return 'create';
|
|
361
|
+
return fields.length ? 'update' : 'unchanged';
|
|
362
|
+
}
|
|
363
|
+
/** The model. A created type brings its table, and never its rows. */
|
|
364
|
+
function planObjectTypes(source, target, selected) {
|
|
332
365
|
const targetTypes = new Map(target.objectTypes.map((t) => [t.name, t]));
|
|
366
|
+
const changes = [];
|
|
333
367
|
for (const type of source.objectTypes) {
|
|
334
368
|
if (!selected('objectType', type.name))
|
|
335
369
|
continue;
|
|
@@ -339,7 +373,7 @@ function planPromotion(input) {
|
|
|
339
373
|
kind: 'objectType',
|
|
340
374
|
id: type.name,
|
|
341
375
|
name: type.displayName,
|
|
342
|
-
action: existing
|
|
376
|
+
action: actionFor(existing, fields),
|
|
343
377
|
fields,
|
|
344
378
|
notes: existing
|
|
345
379
|
? []
|
|
@@ -350,8 +384,12 @@ function planPromotion(input) {
|
|
|
350
384
|
],
|
|
351
385
|
});
|
|
352
386
|
}
|
|
353
|
-
|
|
387
|
+
return changes;
|
|
388
|
+
}
|
|
389
|
+
/** Transforms. The target bumps its own version; see the note on `planPromotion`. */
|
|
390
|
+
function planTransforms(source, target, selected) {
|
|
354
391
|
const targetTransforms = new Map(target.transforms.map((t) => [t.id, t]));
|
|
392
|
+
const changes = [];
|
|
355
393
|
for (const transform of source.transforms) {
|
|
356
394
|
if (!selected('transform', transform.id))
|
|
357
395
|
continue;
|
|
@@ -361,7 +399,7 @@ function planPromotion(input) {
|
|
|
361
399
|
kind: 'transform',
|
|
362
400
|
id: transform.id,
|
|
363
401
|
name: transform.name,
|
|
364
|
-
action: existing
|
|
402
|
+
action: actionFor(existing, fields),
|
|
365
403
|
fields,
|
|
366
404
|
notes: existing && fields.length
|
|
367
405
|
? [
|
|
@@ -370,18 +408,21 @@ function planPromotion(input) {
|
|
|
370
408
|
: [],
|
|
371
409
|
});
|
|
372
410
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
411
|
+
return changes;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Workflows, compared on the graph hash rather than field by field.
|
|
415
|
+
*
|
|
416
|
+
* Node positions and names are in the record but not in the hash, so moving a
|
|
417
|
+
* box on a canvas is correctly reported as nothing to release.
|
|
418
|
+
*/
|
|
419
|
+
function planWorkflows(source, target, to, selected) {
|
|
377
420
|
const targetWorkflows = new Map((target.workflows ?? []).map((workflow) => [workflow.id, workflow]));
|
|
421
|
+
const changes = [];
|
|
378
422
|
for (const workflow of source.workflows ?? []) {
|
|
379
423
|
if (!selected('workflow', workflow.id))
|
|
380
424
|
continue;
|
|
381
425
|
const existing = targetWorkflows.get(workflow.id);
|
|
382
|
-
// Compared on the hash, not field by field: node positions and names are in
|
|
383
|
-
// the record but not in the hash, so moving a box on a canvas is correctly
|
|
384
|
-
// reported as nothing to release.
|
|
385
426
|
const fields = diffFields(existing, workflow, [
|
|
386
427
|
'name',
|
|
387
428
|
'description',
|
|
@@ -392,7 +433,7 @@ function planPromotion(input) {
|
|
|
392
433
|
kind: 'workflow',
|
|
393
434
|
id: workflow.id,
|
|
394
435
|
name: workflow.name,
|
|
395
|
-
action: existing
|
|
436
|
+
action: actionFor(existing, fields),
|
|
396
437
|
fields,
|
|
397
438
|
notes: existing && existing.graphHash !== workflow.graphHash
|
|
398
439
|
? [
|
|
@@ -401,13 +442,24 @@ function planPromotion(input) {
|
|
|
401
442
|
: [],
|
|
402
443
|
});
|
|
403
444
|
}
|
|
404
|
-
|
|
445
|
+
return changes;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Connectors, and everything that refuses one.
|
|
449
|
+
*
|
|
450
|
+
* The only section that produces blockers and withheld entries, because it is
|
|
451
|
+
* the only one whose subject points at other things: a connection that must
|
|
452
|
+
* already exist in the target, and code that must either be there or be in this
|
|
453
|
+
* same promotion. `earlier` is what the promotion is already carrying.
|
|
454
|
+
*/
|
|
455
|
+
function planConnectors(input) {
|
|
456
|
+
const { source, target, from, to, selected, earlier } = input;
|
|
405
457
|
const targetConnectors = new Map(target.connectors.map((c) => [c.id, c]));
|
|
406
458
|
const targetConnections = new Map(target.connections.map((c) => [c.id, c]));
|
|
407
|
-
const
|
|
408
|
-
const
|
|
409
|
-
const
|
|
410
|
-
const
|
|
459
|
+
const known = knownCodeIds(target, earlier);
|
|
460
|
+
const changes = [];
|
|
461
|
+
const blockers = [];
|
|
462
|
+
const withheld = [];
|
|
411
463
|
for (const connector of source.connectors) {
|
|
412
464
|
if (!selected('connector', connector.id))
|
|
413
465
|
continue;
|
|
@@ -425,79 +477,18 @@ function planPromotion(input) {
|
|
|
425
477
|
'mode',
|
|
426
478
|
]);
|
|
427
479
|
const notes = [];
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
kind: 'connection',
|
|
438
|
-
id: connector.connectionId,
|
|
439
|
-
name: sourceConnection?.name ?? connector.connectionId,
|
|
440
|
-
reason: `"${connector.name}" reads through connection ${connector.connectionId}, which does not exist in ${to}. Create it there, pointed at ${to}'s own system and with ${to}'s own credential, and run the preview again. A promotion will not create it: a connection is an address and a credential reference, and copying ${from}'s would point ${to} at ${from}'s data.`,
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
|
-
else {
|
|
444
|
-
withheld.push({
|
|
445
|
-
kind: 'connection',
|
|
446
|
-
id: match.id,
|
|
447
|
-
name: match.name,
|
|
448
|
-
fields: exports.PROMOTION_WITHHELD_CONNECTION_FIELDS,
|
|
449
|
-
why: `Matched by id to ${to}'s own "${match.name}". Its address and credential stay exactly as ${to} has them.`,
|
|
450
|
-
});
|
|
451
|
-
if (match.kind !== connector.kind) {
|
|
452
|
-
blockers.push({
|
|
453
|
-
kind: 'connection',
|
|
454
|
-
id: match.id,
|
|
455
|
-
name: match.name,
|
|
456
|
-
reason: `${to}'s connection "${match.name}" is a ${match.kind} connection, but "${connector.name}" expects a ${connector.kind} one. Same id, different kind of system — the load would fail on its first run, or worse, read something that happens to parse.`,
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
else {
|
|
462
|
-
notes.push(`Carries its own source configuration rather than reading through a connection, so whatever address is in its config is being promoted verbatim. Check it names something ${to} should be reading.`);
|
|
463
|
-
}
|
|
464
|
-
// A connector pointing at code that is not there is a load that fails on
|
|
465
|
-
// its first scheduled run, at night, in the target — the worst place to
|
|
466
|
-
// discover it. Caught here, where somebody is looking.
|
|
467
|
-
if (connector.transformId &&
|
|
468
|
-
!targetTransformIds.has(connector.transformId) &&
|
|
469
|
-
!promotedTransformIds.has(connector.transformId)) {
|
|
470
|
-
blockers.push({
|
|
471
|
-
kind: 'connector',
|
|
472
|
-
id: connector.id,
|
|
473
|
-
name: connector.name,
|
|
474
|
-
reason: `"${connector.name}" runs transform ${connector.transformId}, which is neither in ${to} nor included in this promotion. Add it to the selection, or the connector would arrive pointing at code that does not exist.`,
|
|
475
|
-
});
|
|
476
|
-
}
|
|
477
|
-
// The same hole, one level up. A workflow is a graph of transforms, so a
|
|
478
|
-
// connector arriving without it is worse than one arriving without a
|
|
479
|
-
// transform: nothing about the load is defined at all.
|
|
480
|
-
if (connector.workflowId &&
|
|
481
|
-
!targetWorkflowIds.has(connector.workflowId) &&
|
|
482
|
-
!promotedWorkflowIds.has(connector.workflowId)) {
|
|
483
|
-
blockers.push({
|
|
484
|
-
kind: 'connector',
|
|
485
|
-
id: connector.id,
|
|
486
|
-
name: connector.name,
|
|
487
|
-
reason: `"${connector.name}" runs workflow ${connector.workflowId}, which is neither in ${to} nor included in this promotion. Promote the workflow first, or the connector would arrive pointing at a graph that does not exist.`,
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
|
-
withheld.push({
|
|
491
|
-
kind: 'connector',
|
|
492
|
-
id: connector.id,
|
|
493
|
-
name: connector.name,
|
|
494
|
-
fields: existing
|
|
495
|
-
? exports.PROMOTION_WITHHELD_CONNECTOR_FIELDS.filter((field) => field !== 'enabled')
|
|
496
|
-
: exports.PROMOTION_WITHHELD_CONNECTOR_FIELDS,
|
|
497
|
-
why: existing
|
|
498
|
-
? `${to} keeps its own watermark, its own run history, its own credential reference and its own enabled/disabled switch.`
|
|
499
|
-
: `Arrives disabled, with no watermark and no credential reference. Point it at ${to}'s secret and enable it when somebody is watching.`,
|
|
480
|
+
checkConnectorConnection({
|
|
481
|
+
connector,
|
|
482
|
+
source,
|
|
483
|
+
targetConnections,
|
|
484
|
+
from,
|
|
485
|
+
to,
|
|
486
|
+
blockers,
|
|
487
|
+
withheld,
|
|
488
|
+
notes,
|
|
500
489
|
});
|
|
490
|
+
checkConnectorCode({ connector, known, to, blockers });
|
|
491
|
+
withheld.push(connectorWithheld(connector, existing !== undefined, to));
|
|
501
492
|
if (!existing) {
|
|
502
493
|
notes.push('Arrives disabled. Enable it in the target once its connection has been checked.');
|
|
503
494
|
}
|
|
@@ -505,20 +496,107 @@ function planPromotion(input) {
|
|
|
505
496
|
kind: 'connector',
|
|
506
497
|
id: connector.id,
|
|
507
498
|
name: connector.name,
|
|
508
|
-
action: existing
|
|
499
|
+
action: actionFor(existing, fields),
|
|
509
500
|
fields,
|
|
510
501
|
notes,
|
|
511
502
|
});
|
|
512
503
|
}
|
|
513
|
-
|
|
504
|
+
return { changes, blockers, withheld };
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* That the connector's connection exists in the target, and is the same kind.
|
|
508
|
+
*
|
|
509
|
+
* A connector reads through a connection, and the connection is the one thing
|
|
510
|
+
* that is genuinely per-environment. Requiring it to pre-exist is what stops a
|
|
511
|
+
* promotion from silently repointing the target at the source's database.
|
|
512
|
+
*/
|
|
513
|
+
function checkConnectorConnection(input) {
|
|
514
|
+
const { connector, source, targetConnections, from, to, blockers, withheld, notes } = input;
|
|
515
|
+
if (!connector.connectionId) {
|
|
516
|
+
notes.push(`Carries its own source configuration rather than reading through a connection, so whatever address is in its config is being promoted verbatim. Check it names something ${to} should be reading.`);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const match = targetConnections.get(connector.connectionId);
|
|
520
|
+
if (!match) {
|
|
521
|
+
const sourceConnection = source.connections.find((candidate) => candidate.id === connector.connectionId);
|
|
522
|
+
blockers.push({
|
|
523
|
+
kind: 'connection',
|
|
524
|
+
id: connector.connectionId,
|
|
525
|
+
name: sourceConnection?.name ?? connector.connectionId,
|
|
526
|
+
reason: `"${connector.name}" reads through connection ${connector.connectionId}, which does not exist in ${to}. Create it there, pointed at ${to}'s own system and with ${to}'s own credential, and run the preview again. A promotion will not create it: a connection is an address and a credential reference, and copying ${from}'s would point ${to} at ${from}'s data.`,
|
|
527
|
+
});
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
withheld.push({
|
|
531
|
+
kind: 'connection',
|
|
532
|
+
id: match.id,
|
|
533
|
+
name: match.name,
|
|
534
|
+
fields: exports.PROMOTION_WITHHELD_CONNECTION_FIELDS,
|
|
535
|
+
why: `Matched by id to ${to}'s own "${match.name}". Its address and credential stay exactly as ${to} has them.`,
|
|
536
|
+
});
|
|
537
|
+
if (match.kind !== connector.kind) {
|
|
538
|
+
blockers.push({
|
|
539
|
+
kind: 'connection',
|
|
540
|
+
id: match.id,
|
|
541
|
+
name: match.name,
|
|
542
|
+
reason: `${to}'s connection "${match.name}" is a ${match.kind} connection, but "${connector.name}" expects a ${connector.kind} one. Same id, different kind of system — the load would fail on its first run, or worse, read something that happens to parse.`,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* That the code a connector runs will be there when it arrives.
|
|
548
|
+
*
|
|
549
|
+
* A connector pointing at code that is not there is a load that fails on its
|
|
550
|
+
* first scheduled run, at night, in the target — the worst place to discover it.
|
|
551
|
+
* Caught here, where somebody is looking. Either the target already has it, or
|
|
552
|
+
* this promotion is carrying it.
|
|
553
|
+
*/
|
|
554
|
+
function checkConnectorCode(input) {
|
|
555
|
+
const { connector, known, to, blockers } = input;
|
|
556
|
+
const { transformId, workflowId } = connector;
|
|
557
|
+
if (transformId &&
|
|
558
|
+
!known.transforms.has(transformId) &&
|
|
559
|
+
!known.arrivingTransforms.has(transformId)) {
|
|
560
|
+
blockers.push({
|
|
561
|
+
kind: 'connector',
|
|
562
|
+
id: connector.id,
|
|
563
|
+
name: connector.name,
|
|
564
|
+
reason: `"${connector.name}" runs transform ${transformId}, which is neither in ${to} nor included in this promotion. Add it to the selection, or the connector would arrive pointing at code that does not exist.`,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
// The same hole, one level up. A workflow is a graph of transforms, so a
|
|
568
|
+
// connector arriving without it is worse than one arriving without a
|
|
569
|
+
// transform: nothing about the load is defined at all.
|
|
570
|
+
if (workflowId && !known.workflows.has(workflowId) && !known.arrivingWorkflows.has(workflowId)) {
|
|
571
|
+
blockers.push({
|
|
572
|
+
kind: 'connector',
|
|
573
|
+
id: connector.id,
|
|
574
|
+
name: connector.name,
|
|
575
|
+
reason: `"${connector.name}" runs workflow ${workflowId}, which is neither in ${to} nor included in this promotion. Promote the workflow first, or the connector would arrive pointing at a graph that does not exist.`,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
function knownCodeIds(target, earlier) {
|
|
580
|
+
const arriving = (kind) => new Set(earlier.filter((change) => change.kind === kind).map((change) => change.id));
|
|
514
581
|
return {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
582
|
+
transforms: new Set(target.transforms.map((t) => t.id)),
|
|
583
|
+
arrivingTransforms: arriving('transform'),
|
|
584
|
+
workflows: new Set((target.workflows ?? []).map((workflow) => workflow.id)),
|
|
585
|
+
arrivingWorkflows: arriving('workflow'),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
/** What the target keeps for itself when a connector lands on it. */
|
|
589
|
+
function connectorWithheld(connector, exists, to) {
|
|
590
|
+
return {
|
|
591
|
+
kind: 'connector',
|
|
592
|
+
id: connector.id,
|
|
593
|
+
name: connector.name,
|
|
594
|
+
fields: exists
|
|
595
|
+
? exports.PROMOTION_WITHHELD_CONNECTOR_FIELDS.filter((field) => field !== 'enabled')
|
|
596
|
+
: exports.PROMOTION_WITHHELD_CONNECTOR_FIELDS,
|
|
597
|
+
why: exists
|
|
598
|
+
? `${to} keeps its own watermark, its own run history, its own credential reference and its own enabled/disabled switch.`
|
|
599
|
+
: `Arrives disabled, with no watermark and no credential reference. Point it at ${to}'s secret and enable it when somebody is watching.`,
|
|
522
600
|
};
|
|
523
601
|
}
|
|
524
602
|
/** Whether a plan may be applied at all. */
|
|
@@ -568,7 +646,7 @@ function fingerprintOf(from, to, changes, blockers) {
|
|
|
568
646
|
*/
|
|
569
647
|
function stable(value) {
|
|
570
648
|
if (value === undefined)
|
|
571
|
-
return '
|
|
649
|
+
return '\u0000undefined';
|
|
572
650
|
if (value === null)
|
|
573
651
|
return 'null';
|
|
574
652
|
if (Array.isArray(value))
|
package/dist/catalog.pipeline.js
CHANGED
|
@@ -208,6 +208,40 @@ function validateWorkflow(graph) {
|
|
|
208
208
|
},
|
|
209
209
|
];
|
|
210
210
|
}
|
|
211
|
+
const byId = collectNodesById(nodes, issues);
|
|
212
|
+
checkEdges(edges, byId, issues);
|
|
213
|
+
// Reachability and cycles are only meaningful once the graph is structurally
|
|
214
|
+
// sound. See the note on this function.
|
|
215
|
+
if (issues.length > 0)
|
|
216
|
+
return issues;
|
|
217
|
+
const { outgoing, incoming } = buildAdjacency(nodes, edges);
|
|
218
|
+
const sources = nodes.filter((node) => node.kind === 'source');
|
|
219
|
+
const sinks = nodes.filter((node) => node.kind === 'sink');
|
|
220
|
+
checkNodeWiring(nodes, incoming, outgoing, issues);
|
|
221
|
+
checkEndpoints(sources, sinks, issues);
|
|
222
|
+
const looped = findCycle(nodes, incoming, outgoing);
|
|
223
|
+
if (looped) {
|
|
224
|
+
issues.push({
|
|
225
|
+
code: 'cycle',
|
|
226
|
+
nodeIds: looped,
|
|
227
|
+
message: `These nodes form a cycle: ${looped.join(' → ')}. A graph that loops has no order to run in and no point at which the load is finished, so it is refused rather than run until something times out.`,
|
|
228
|
+
});
|
|
229
|
+
// Reachability over a cyclic graph reports nodes as unreachable that are
|
|
230
|
+
// only unreachable *because* of the cycle, which points at the wrong boxes.
|
|
231
|
+
return issues;
|
|
232
|
+
}
|
|
233
|
+
checkReachability(nodes, sources, sinks, incoming, outgoing, issues);
|
|
234
|
+
return issues;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Index the nodes by id, reporting the ids that cannot be used as one.
|
|
238
|
+
*
|
|
239
|
+
* A node with an unusable id is still indexed, deliberately. Leaving it out
|
|
240
|
+
* would make every edge touching it report a *missing node* as well, which
|
|
241
|
+
* sends the reader looking for a node they can see on the canvas. One problem,
|
|
242
|
+
* one message.
|
|
243
|
+
*/
|
|
244
|
+
function collectNodesById(nodes, issues) {
|
|
211
245
|
const byId = new Map();
|
|
212
246
|
for (const node of nodes) {
|
|
213
247
|
if (!exports.WORKFLOW_NODE_ID_PATTERN.test(node.id)) {
|
|
@@ -216,9 +250,6 @@ function validateWorkflow(graph) {
|
|
|
216
250
|
nodeIds: [node.id],
|
|
217
251
|
message: `Node id "${node.id}" is not usable. Ids may be 1-64 characters of letters, digits, underscore or hyphen: the id becomes a durable step name and part of the key its staged rows are stored under, and neither can carry arbitrary text safely.`,
|
|
218
252
|
});
|
|
219
|
-
// Registered anyway, deliberately. Leaving it out would make every edge
|
|
220
|
-
// touching it report a *missing node* as well, which sends the reader
|
|
221
|
-
// looking for a node they can see on the canvas. One problem, one message.
|
|
222
253
|
}
|
|
223
254
|
if (byId.has(node.id)) {
|
|
224
255
|
issues.push({
|
|
@@ -230,6 +261,10 @@ function validateWorkflow(graph) {
|
|
|
230
261
|
}
|
|
231
262
|
byId.set(node.id, node);
|
|
232
263
|
}
|
|
264
|
+
return byId;
|
|
265
|
+
}
|
|
266
|
+
/** Wires that name a node which is not there, loop back on themselves, or repeat. */
|
|
267
|
+
function checkEdges(edges, byId, issues) {
|
|
233
268
|
const seenEdges = new Set();
|
|
234
269
|
for (const edge of edges) {
|
|
235
270
|
if (!byId.has(edge.from) || !byId.has(edge.to)) {
|
|
@@ -260,10 +295,9 @@ function validateWorkflow(graph) {
|
|
|
260
295
|
}
|
|
261
296
|
seenEdges.add(key);
|
|
262
297
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
return issues;
|
|
298
|
+
}
|
|
299
|
+
/** Both directions of the edge list, with an entry for every node. */
|
|
300
|
+
function buildAdjacency(nodes, edges) {
|
|
267
301
|
const outgoing = new Map();
|
|
268
302
|
const incoming = new Map();
|
|
269
303
|
for (const node of nodes) {
|
|
@@ -274,13 +308,11 @@ function validateWorkflow(graph) {
|
|
|
274
308
|
outgoing.get(edge.from)?.push(edge.to);
|
|
275
309
|
incoming.get(edge.to)?.push(edge.from);
|
|
276
310
|
}
|
|
277
|
-
|
|
278
|
-
|
|
311
|
+
return { outgoing, incoming };
|
|
312
|
+
}
|
|
313
|
+
/** What each kind of node may and may not have wired to it. */
|
|
314
|
+
function checkNodeWiring(nodes, incoming, outgoing, issues) {
|
|
279
315
|
for (const node of nodes) {
|
|
280
|
-
if (node.kind === 'source')
|
|
281
|
-
sources.push(node);
|
|
282
|
-
if (node.kind === 'sink')
|
|
283
|
-
sinks.push(node);
|
|
284
316
|
if (node.kind === 'source' && (incoming.get(node.id)?.length ?? 0) > 0) {
|
|
285
317
|
issues.push({
|
|
286
318
|
code: 'source-has-input',
|
|
@@ -303,6 +335,20 @@ function validateWorkflow(graph) {
|
|
|
303
335
|
});
|
|
304
336
|
}
|
|
305
337
|
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* That the graph has both ends, and that no two sinks claim the same type.
|
|
341
|
+
*
|
|
342
|
+
* Several sinks are allowed, and the reason is the point of having a graph at
|
|
343
|
+
* all: one expensive read feeding several outputs. Forbidding it would mean
|
|
344
|
+
* pulling the same ten million rows twice to derive two types from them.
|
|
345
|
+
*
|
|
346
|
+
* What is refused is two sinks writing the *same* type. Each sink commits its
|
|
347
|
+
* own type independently — there is no distributed transaction here and the
|
|
348
|
+
* model does not pretend otherwise — but two snapshots of one type in one run
|
|
349
|
+
* leaves nothing to say which of them the readers should get.
|
|
350
|
+
*/
|
|
351
|
+
function checkEndpoints(sources, sinks, issues) {
|
|
306
352
|
if (sources.length === 0) {
|
|
307
353
|
issues.push({
|
|
308
354
|
code: 'no-source',
|
|
@@ -317,14 +363,6 @@ function validateWorkflow(graph) {
|
|
|
317
363
|
message: 'This workflow has no sink node. A workflow ends at a sink, because the sink is what writes and commits — without one the graph computes rows and throws them away.',
|
|
318
364
|
});
|
|
319
365
|
}
|
|
320
|
-
// Several sinks are allowed, and the reason is the point of having a graph at
|
|
321
|
-
// all: one expensive read feeding several outputs. Forbidding it would mean
|
|
322
|
-
// pulling the same ten million rows twice to derive two types from them.
|
|
323
|
-
//
|
|
324
|
-
// What is refused is two sinks writing the *same* type. Each sink commits its
|
|
325
|
-
// own type independently — there is no distributed transaction here and the
|
|
326
|
-
// model does not pretend otherwise — but two snapshots of one type in one run
|
|
327
|
-
// leaves nothing to say which of them the readers should get.
|
|
328
366
|
const byTargetType = new Map();
|
|
329
367
|
for (const sink of sinks) {
|
|
330
368
|
const sharing = byTargetType.get(sink.targetType) ?? [];
|
|
@@ -342,8 +380,14 @@ function validateWorkflow(graph) {
|
|
|
342
380
|
.join(' and ')} both commit ${targetType}. Two snapshots of one type in a single run leaves nothing to say which one readers should get — wire these branches into one sink, or send them to different types.`,
|
|
343
381
|
});
|
|
344
382
|
}
|
|
345
|
-
|
|
346
|
-
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* The nodes actually on a cycle, or nothing if the graph is acyclic.
|
|
386
|
+
*
|
|
387
|
+
* Kahn's algorithm: whatever is left with a non-zero in-degree after the queue
|
|
388
|
+
* drains is on one.
|
|
389
|
+
*/
|
|
390
|
+
function findCycle(nodes, incoming, outgoing) {
|
|
347
391
|
const indegree = new Map();
|
|
348
392
|
for (const node of nodes) {
|
|
349
393
|
indegree.set(node.id, incoming.get(node.id)?.length ?? 0);
|
|
@@ -362,35 +406,38 @@ function validateWorkflow(graph) {
|
|
|
362
406
|
queue.push(next);
|
|
363
407
|
}
|
|
364
408
|
}
|
|
365
|
-
if (ordered.length
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
409
|
+
if (ordered.length === nodes.length)
|
|
410
|
+
return undefined;
|
|
411
|
+
const leftover = new Set(nodes.filter((node) => !ordered.includes(node.id)).map((node) => node.id));
|
|
412
|
+
return [...peelTails(leftover, outgoing)];
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Strip the nodes that are merely stuck behind a loop, leaving the loop itself.
|
|
416
|
+
*
|
|
417
|
+
* What Kahn's algorithm leaves behind is the cycle *plus* everything downstream
|
|
418
|
+
* of it, because those never had their in-degree resolved either. Naming all of
|
|
419
|
+
* it would point at nodes that are perfectly well wired and merely waiting on
|
|
420
|
+
* the loop, and a message that names the wrong node is worse than a vague one.
|
|
421
|
+
* Removing nodes with no outgoing edge *inside the set*, repeatedly, strips
|
|
422
|
+
* exactly those tails: a node on the cycle always has one.
|
|
423
|
+
*
|
|
424
|
+
* Mutates and returns the set it was given, which is a local built for this.
|
|
425
|
+
*/
|
|
426
|
+
function peelTails(leftover, outgoing) {
|
|
427
|
+
for (let peeled = true; peeled;) {
|
|
428
|
+
peeled = false;
|
|
429
|
+
for (const id of leftover) {
|
|
430
|
+
const continues = (outgoing.get(id) ?? []).some((next) => leftover.has(next));
|
|
431
|
+
if (continues)
|
|
432
|
+
continue;
|
|
433
|
+
leftover.delete(id);
|
|
434
|
+
peeled = true;
|
|
383
435
|
}
|
|
384
|
-
const looped = [...stuck];
|
|
385
|
-
issues.push({
|
|
386
|
-
code: 'cycle',
|
|
387
|
-
nodeIds: looped,
|
|
388
|
-
message: `These nodes form a cycle: ${looped.join(' → ')}. A graph that loops has no order to run in and no point at which the load is finished, so it is refused rather than run until something times out.`,
|
|
389
|
-
});
|
|
390
|
-
// Reachability over a cyclic graph reports nodes as unreachable that are
|
|
391
|
-
// only unreachable *because* of the cycle, which points at the wrong boxes.
|
|
392
|
-
return issues;
|
|
393
436
|
}
|
|
437
|
+
return leftover;
|
|
438
|
+
}
|
|
439
|
+
/** Nodes that no source reaches, and nodes that reach no sink. */
|
|
440
|
+
function checkReachability(nodes, sources, sinks, incoming, outgoing, issues) {
|
|
394
441
|
const reachableFromSources = walk(sources.map((node) => node.id), outgoing);
|
|
395
442
|
const reachesASink = walk(sinks.map((sink) => sink.id), incoming);
|
|
396
443
|
for (const node of nodes) {
|
|
@@ -410,7 +457,6 @@ function validateWorkflow(graph) {
|
|
|
410
457
|
});
|
|
411
458
|
}
|
|
412
459
|
}
|
|
413
|
-
return issues;
|
|
414
460
|
}
|
|
415
461
|
/** Breadth-first reachability over one adjacency map. */
|
|
416
462
|
function walk(roots, adjacency) {
|
|
@@ -447,16 +493,11 @@ function workflowRunOrder(graph) {
|
|
|
447
493
|
throw new Error(`Refusing to order an invalid workflow: ${issues.map((issue) => issue.message).join(' ')}`);
|
|
448
494
|
}
|
|
449
495
|
const byId = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}
|
|
456
|
-
for (const edge of graph.edges) {
|
|
457
|
-
indegree.set(edge.to, (indegree.get(edge.to) ?? 0) + 1);
|
|
458
|
-
outgoing.get(edge.from)?.push(edge.to);
|
|
459
|
-
}
|
|
496
|
+
// The same adjacency the validator walks, from the same builder. Two copies of
|
|
497
|
+
// "what is wired into what" is exactly how a graph that validated comes out
|
|
498
|
+
// executing differently, which is the thing this function's contract rules out.
|
|
499
|
+
const { outgoing, incoming } = buildAdjacency(graph.nodes, graph.edges);
|
|
500
|
+
const indegree = new Map(graph.nodes.map((node) => [node.id, incoming.get(node.id)?.length ?? 0]));
|
|
460
501
|
const ready = graph.nodes.filter((node) => indegree.get(node.id) === 0).map((node) => node.id);
|
|
461
502
|
const order = [];
|
|
462
503
|
while (ready.length > 0) {
|
|
@@ -468,9 +509,9 @@ function workflowRunOrder(graph) {
|
|
|
468
509
|
continue;
|
|
469
510
|
// Edge order, not node order: this is the array a merge reads its inputs
|
|
470
511
|
// from, and it is part of the fingerprint precisely because it is visible in
|
|
471
|
-
// the output.
|
|
472
|
-
|
|
473
|
-
order.push({ node, inputs });
|
|
512
|
+
// the output. `buildAdjacency` fills `incoming` by walking the edges in
|
|
513
|
+
// order, so that is what this already is.
|
|
514
|
+
order.push({ node, inputs: [...(incoming.get(id) ?? [])] });
|
|
474
515
|
for (const next of outgoing.get(id) ?? []) {
|
|
475
516
|
const remaining = (indegree.get(next) ?? 0) - 1;
|
|
476
517
|
indegree.set(next, remaining);
|
|
Binary file
|
package/dist/catalog.registry.js
CHANGED
|
@@ -296,10 +296,7 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
|
|
|
296
296
|
return;
|
|
297
297
|
const fromDecorator = declaredProps[prop.name];
|
|
298
298
|
const fromOverlay = overlayProps[prop.name];
|
|
299
|
-
const displayName
|
|
300
|
-
const description = fromOverlay?.description ?? fromDecorator?.description;
|
|
301
|
-
const hidden = fromOverlay?.hidden ?? fromDecorator?.hidden ?? false;
|
|
302
|
-
const order = fromOverlay?.order ?? fromDecorator?.order ?? index;
|
|
299
|
+
const { displayName, description, hidden, order } = resolveFieldPresentation(prop.name, index, fromDecorator, fromOverlay);
|
|
303
300
|
if (isRelationKind(prop.kind)) {
|
|
304
301
|
relations.push({
|
|
305
302
|
name: prop.name,
|
|
@@ -357,3 +354,19 @@ exports.MikroOrmCatalogRegistry = MikroOrmCatalogRegistry = MikroOrmCatalogRegis
|
|
|
357
354
|
__param(2, (0, common_1.Inject)(catalog_overlay_store_token_1.CATALOG_OVERLAY_STORE)),
|
|
358
355
|
__metadata("design:paramtypes", [core_1.MikroORM, Object, Object])
|
|
359
356
|
], MikroOrmCatalogRegistry);
|
|
357
|
+
/**
|
|
358
|
+
* How one field is presented, resolved across the tiers.
|
|
359
|
+
*
|
|
360
|
+
* Overlay beats decorator beats a derived default, the same precedence the
|
|
361
|
+
* type-level fields use. Split out of `buildType` because it is the one job in
|
|
362
|
+
* that loop which is identical whether the field turns out to be a scalar or a
|
|
363
|
+
* relation — both branches consume exactly this.
|
|
364
|
+
*/
|
|
365
|
+
function resolveFieldPresentation(name, index, fromDecorator, fromOverlay) {
|
|
366
|
+
return {
|
|
367
|
+
displayName: fromOverlay?.displayName ?? fromDecorator?.displayName ?? humanize(name),
|
|
368
|
+
description: fromOverlay?.description ?? fromDecorator?.description,
|
|
369
|
+
hidden: fromOverlay?.hidden ?? fromDecorator?.hidden ?? false,
|
|
370
|
+
order: fromOverlay?.order ?? fromDecorator?.order ?? index,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
@@ -79,6 +79,22 @@ export interface DashboardCard {
|
|
|
79
79
|
savedQueryId: string;
|
|
80
80
|
/** Overrides the saved query's own title on this dashboard. */
|
|
81
81
|
title?: string;
|
|
82
|
+
/**
|
|
83
|
+
* Overrides the saved query's chart library on this dashboard.
|
|
84
|
+
*
|
|
85
|
+
* Separate from the query's own choice because the two answer different
|
|
86
|
+
* questions. The query says how this ANSWER is best drawn, once, wherever it
|
|
87
|
+
* appears; the card says how it should look HERE, next to the other cards on
|
|
88
|
+
* this board. A dashboard that mixes two chart libraries' idea of a bar looks
|
|
89
|
+
* like two dashboards, and fixing that by editing the saved query would
|
|
90
|
+
* change it on every other board that uses it.
|
|
91
|
+
*
|
|
92
|
+
* Undefined falls back to the query's `visualization.library`, which in turn
|
|
93
|
+
* falls back to the built-in renderer. A name nobody registered degrades to
|
|
94
|
+
* the built-in rather than failing — a dashboard should come back plainer,
|
|
95
|
+
* not broken.
|
|
96
|
+
*/
|
|
97
|
+
library?: string;
|
|
82
98
|
/** 1–4, in a twelve-column grid. Kept coarse on purpose. */
|
|
83
99
|
width: 1 | 2 | 3 | 4;
|
|
84
100
|
position: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dudousxd/nestjs-catalog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A metadata registry for NestJS: object types, properties and relations, derived from your ORM and enriched with decorators.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Davide Carvalho",
|