@dudousxd/nestjs-catalog 0.3.1 → 0.4.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.
@@ -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
- // --- The model -------------------------------------------------------------
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 ? (fields.length ? 'update' : 'unchanged') : 'create',
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
- // --- Transforms ------------------------------------------------------------
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 ? (fields.length ? 'update' : 'unchanged') : 'create',
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
- // --- Workflows -------------------------------------------------------------
374
- // Before connectors, deliberately: the order changes are listed is the order
375
- // they must be applied, and a connector arriving before the graph it runs
376
- // would point at nothing for as long as the apply takes.
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 ? (fields.length ? 'update' : 'unchanged') : 'create',
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
- // --- Connectors ------------------------------------------------------------
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 targetTransformIds = new Set(target.transforms.map((t) => t.id));
408
- const promotedTransformIds = new Set(changes.filter((change) => change.kind === 'transform').map((change) => change.id));
409
- const targetWorkflowIds = new Set((target.workflows ?? []).map((workflow) => workflow.id));
410
- const promotedWorkflowIds = new Set(changes.filter((change) => change.kind === 'workflow').map((change) => change.id));
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
- // A connector reads through a connection, and the connection is the one
429
- // thing that is genuinely per-environment. Requiring it to pre-exist is
430
- // what stops a promotion from silently repointing the target at the
431
- // source's database.
432
- if (connector.connectionId) {
433
- const match = targetConnections.get(connector.connectionId);
434
- if (!match) {
435
- const sourceConnection = source.connections.find((candidate) => candidate.id === connector.connectionId);
436
- blockers.push({
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 ? (fields.length ? 'update' : 'unchanged') : 'create',
499
+ action: actionFor(existing, fields),
509
500
  fields,
510
501
  notes,
511
502
  });
512
503
  }
513
- const createdAt = (input.now?.() ?? new Date()).toISOString();
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
- from,
516
- to,
517
- createdAt,
518
- changes,
519
- blockers,
520
- withheld,
521
- fingerprint: fingerprintOf(from, to, changes, blockers),
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 'undefined';
649
+ return '\u0000undefined';
572
650
  if (value === null)
573
651
  return 'null';
574
652
  if (Array.isArray(value))
@@ -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
- // Reachability and cycles are only meaningful once the graph is structurally
264
- // sound. See the note on this function.
265
- if (issues.length > 0)
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
- const sources = [];
278
- const sinks = [];
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
- // Cycles, by Kahn's algorithm: whatever is left with a non-zero in-degree
346
- // after the queue drains is on one.
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 !== nodes.length) {
366
- // What Kahn's algorithm leaves behind is the cycle *plus* everything
367
- // downstream of it, because those never had their in-degree resolved
368
- // either. Naming all of it would point at nodes that are perfectly well
369
- // wired and merely stuck behind the loop, and a message that names the
370
- // wrong node is worse than a vague one. Peeling off nodes with no outgoing
371
- // edge inside the leftover set, repeatedly, strips exactly those tails and
372
- // leaves the nodes actually on the loop.
373
- const stuck = new Set(nodes.filter((node) => !ordered.includes(node.id)).map((node) => node.id));
374
- for (let peeled = true; peeled;) {
375
- peeled = false;
376
- for (const id of stuck) {
377
- const continues = (outgoing.get(id) ?? []).some((next) => stuck.has(next));
378
- if (continues)
379
- continue;
380
- stuck.delete(id);
381
- peeled = true;
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
- const indegree = new Map();
451
- const outgoing = new Map();
452
- for (const node of graph.nodes) {
453
- indegree.set(node.id, 0);
454
- outgoing.set(node.id, []);
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
- const inputs = graph.edges.filter((edge) => edge.to === id).map((edge) => edge.from);
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
@@ -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 = fromOverlay?.displayName ?? fromDecorator?.displayName ?? humanize(prop.name);
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
+ }
@@ -23,7 +23,20 @@ export interface CatalogPropertyDef {
23
23
  name: string;
24
24
  /** Human label. Tier 0: editable at runtime, no migration. */
25
25
  displayName: string;
26
- /** What this field means in the business. Tier 0. */
26
+ /**
27
+ * What this field means in the business. Tier 0.
28
+ *
29
+ * **Markdown**, since the console started editing it as rich text. Plain text
30
+ * is still valid markdown, so anything written before — or declared by a
31
+ * decorator — is unchanged and renders as itself.
32
+ *
33
+ * Consumers that print it raw keep working: the worst they will see is
34
+ * `**bold**`, which reads. That is the whole reason it is not HTML — `<strong>`
35
+ * in a tooltip is noise, and a consumer that decided to render it as HTML
36
+ * would have an injection point. Render it with a markdown renderer if you
37
+ * want the formatting; `RichTextView` in `@dudousxd/nestjs-catalog-react` is
38
+ * the one this console uses.
39
+ */
27
40
  description?: string;
28
41
  type: ScalarType;
29
42
  /** The physical column. Derived from the ORM; shown so engineers can trace it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dudousxd/nestjs-catalog",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
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",