@memberjunction/record-graph 0.0.0 → 6.2.0-edge.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,847 @@
1
+ import { CompositeKey, Metadata, RunView, LogError, LogStatus, } from '@memberjunction/core';
2
+ import { EscapeSQLString, NormalizeUUID, UUIDsEqual } from '@memberjunction/global';
3
+ import { BuildCompositeKeyFromRecord } from './keys.js';
4
+ import { SortNodesTopologically } from './sort.js';
5
+ import { SYSTEM_FK_SKIP_PATTERNS } from './constants.js';
6
+ /**
7
+ * Walks a record's relationship graph to discover all dependent and referenced records.
8
+ *
9
+ * Traversal rules:
10
+ * - Reverse walk: follows curated One-To-Many relationships (where child points to parent).
11
+ * Recurses with 'full' mode (both reverse + forward).
12
+ * - Forward walk: follows foreign keys on the parent (where parent points to another entity).
13
+ * Recurses with 'forward-only' mode — targets do NOT discover their own reverse children,
14
+ * preventing graph explosion from shared/hub entities.
15
+ * - Subtypes: follows IS-A inheritance if IncludeSubtypes is true.
16
+ * - Hierarchies: follows self-referencing hierarchy FKs if FollowHierarchies is true.
17
+ * - Soft links: follows inbound polymorphic EntityID/RecordID links if IncludeSoftLinks is true.
18
+ * - Non-curated inbound FKs: discovers non-curated inbound FKs if ListNonCuratedInbound is true.
19
+ * - EdgePolicy: callers can supply an EdgePolicy callback to dictate Deep / Reference / Skip per edge.
20
+ * - Cycle prevention: uses ancestorStack (entity types) to prevent backtracking and a global visited set (records).
21
+ */
22
+ export class DependencyGraphWalker {
23
+ constructor(provider) {
24
+ /** Cache: entityID → reverse relationships from EntityRelationship metadata */
25
+ this.reverseRelCache = new Map();
26
+ /** Cache: entityID → forward FK references from field metadata */
27
+ this.forwardRefCache = new Map();
28
+ this._provider = provider;
29
+ }
30
+ /** Returns the active provider — explicit override if set, otherwise the global default. */
31
+ get ProviderToUse() {
32
+ return (this._provider ?? Metadata.Provider);
33
+ }
34
+ // =========================================================================
35
+ // Public API
36
+ // =========================================================================
37
+ /**
38
+ * Walk from a root record through its relationships, building a tree of
39
+ * all records that should be included in a version label or clone plan.
40
+ *
41
+ * @param entityName - The starting entity name
42
+ * @param recordKey - The starting record's primary key
43
+ * @param options - Controls depth, filtering, policies, etc.
44
+ * @param contextUser - Server-side user context
45
+ * @returns The root DependencyNode with all descendants populated
46
+ */
47
+ async WalkDependents(entityName, recordKey, options, contextUser) {
48
+ const md = this.ProviderToUse;
49
+ const entityInfo = md.EntityByName(entityName);
50
+ if (!entityInfo) {
51
+ throw new Error(`Entity '${entityName}' not found in metadata`);
52
+ }
53
+ const resolvedOptions = this.resolveDefaults(options);
54
+ const visited = new Set();
55
+ const ancestorStack = new Set();
56
+ const stats = this.createEmptyStats();
57
+ this.reverseRelCache.clear();
58
+ this.forwardRefCache.clear();
59
+ const rootData = await this.loadRecordData(entityInfo, recordKey, contextUser);
60
+ const rootNode = {
61
+ EntityName: entityName,
62
+ EntityInfo: entityInfo,
63
+ RecordKey: recordKey,
64
+ RecordID: recordKey.ToConcatenatedString(),
65
+ RecordData: rootData,
66
+ Relationship: null,
67
+ Children: [],
68
+ Depth: 0,
69
+ DiscoveringEdge: null,
70
+ };
71
+ // Mark root as visited and push its entity type onto the ancestor stack
72
+ visited.add(this.visitKey(entityInfo.Name, rootNode.RecordID));
73
+ ancestorStack.add(entityInfo.Name);
74
+ this.incrementEntityCount(stats, entityInfo.Name);
75
+ // Root always gets full discovery mode (both reverse + forward); walkChildren also walks
76
+ // the root's IS-A subtypes when IncludeSubtypes is on.
77
+ await this.walkChildren(rootNode, 'full', resolvedOptions, visited, ancestorStack, stats, contextUser);
78
+ // Pop root (cleanup)
79
+ ancestorStack.delete(entityInfo.Name);
80
+ this.logWalkSummary(entityName, resolvedOptions, stats);
81
+ return rootNode;
82
+ }
83
+ /**
84
+ * Flatten a dependency tree into a topologically sorted list.
85
+ * Parents appear before their children, ensuring safe restore or clone execution ordering.
86
+ */
87
+ FlattenTopological(root) {
88
+ return SortNodesTopologically(root);
89
+ }
90
+ // =========================================================================
91
+ // Core Walk Logic
92
+ // =========================================================================
93
+ /**
94
+ * Recursively discover and attach child nodes for a given parent node.
95
+ */
96
+ async walkChildren(parentNode, discoveryMode, options, visited, ancestorStack, stats, contextUser) {
97
+ if (parentNode.Depth >= options.MaxDepth) {
98
+ return;
99
+ }
100
+ // Reverse walk: only when in full mode (root or reverse-discovered nodes)
101
+ if (discoveryMode === 'full') {
102
+ await this.walkReverseRelationships(parentNode, options, visited, ancestorStack, stats, contextUser);
103
+ if (options.ListNonCuratedInbound) {
104
+ await this.walkNonCuratedInbound(parentNode, options, visited, ancestorStack, stats, contextUser);
105
+ }
106
+ if (options.IncludeSoftLinks) {
107
+ await this.walkSoftLinks(parentNode, options, visited, ancestorStack, stats, contextUser);
108
+ }
109
+ }
110
+ else {
111
+ stats.ForwardOnlySuppressions++;
112
+ }
113
+ // Forward walk: always performed regardless of discovery mode
114
+ await this.walkForwardReferences(parentNode, options, visited, ancestorStack, stats, contextUser);
115
+ // Subtypes walk: if enabled, discover IS-A rows
116
+ if (options.IncludeSubtypes) {
117
+ await this.walkSubtypes(parentNode, options, visited, ancestorStack, stats, contextUser);
118
+ }
119
+ }
120
+ // =========================================================================
121
+ // Reverse Walking (EntityRelationship-driven)
122
+ // =========================================================================
123
+ /**
124
+ * Walk reverse relationships: for each EntityRelationship on the parent
125
+ * entity, load matching child records and recurse into them.
126
+ */
127
+ async walkReverseRelationships(parentNode, options, visited, ancestorStack, stats, contextUser) {
128
+ const relationships = this.discoverReverseRelationships(parentNode.EntityInfo, options);
129
+ for (const rel of relationships) {
130
+ const isHierarchyRel = UUIDsEqual(rel.ChildEntityInfo.ID, parentNode.EntityInfo.ID);
131
+ if (isHierarchyRel && !options.FollowHierarchies) {
132
+ continue;
133
+ }
134
+ // Ancestor check: skip if the child entity type is on our current path (unless allowed hierarchy recursion)
135
+ if (ancestorStack.has(rel.ChildEntityInfo.Name) && !(isHierarchyRel && options.FollowHierarchies)) {
136
+ stats.AncestorSkips++;
137
+ continue;
138
+ }
139
+ if (this.shouldSkipEntity(rel.ChildEntityInfo.Name, options)) {
140
+ continue;
141
+ }
142
+ const edgeKind = isHierarchyRel ? 'Hierarchy' : (rel.IsCollection ? 'Collection' : 'Relationship');
143
+ // Check edge policy before loading if policy is configured
144
+ const edgeCandidate = {
145
+ Kind: edgeKind,
146
+ SourceEntityName: parentNode.EntityName,
147
+ SourceKey: parentNode.RecordKey,
148
+ SourceRecordData: parentNode.RecordData,
149
+ TargetEntityName: rel.ChildEntityInfo.Name,
150
+ JoinField: rel.ChildJoinField,
151
+ Relationship: rel.RelationshipInfo,
152
+ CollectionName: rel.CollectionName,
153
+ IsSoftLink: false,
154
+ Depth: parentNode.Depth + 1,
155
+ HierarchyDirection: isHierarchyRel ? 'Down' : undefined,
156
+ };
157
+ const edgeDecision = options.EdgePolicy ? options.EdgePolicy(edgeCandidate) : 'Deep';
158
+ if (edgeDecision === 'Skip') {
159
+ continue;
160
+ }
161
+ const childRecords = await this.loadChildRecords(parentNode, rel, options, contextUser, (message) => options.OnLoadFailure?.({ Edge: edgeCandidate, Decision: edgeDecision, Message: message }));
162
+ for (const childData of childRecords) {
163
+ const childKey = BuildCompositeKeyFromRecord(rel.ChildEntityInfo, childData);
164
+ const discoveringEdge = {
165
+ Kind: edgeKind,
166
+ FromKey: this.visitKey(parentNode.EntityName, parentNode.RecordID),
167
+ ToKey: this.visitKey(rel.ChildEntityInfo.Name, childKey.ToConcatenatedString()),
168
+ SourceEntityName: parentNode.EntityName,
169
+ TargetEntityName: rel.ChildEntityInfo.Name,
170
+ JoinField: rel.ChildJoinField,
171
+ Relationship: rel.RelationshipInfo,
172
+ CollectionName: rel.CollectionName,
173
+ IsSoftLink: false,
174
+ };
175
+ // Re-evaluate EdgePolicy per-record if candidate target key is known
176
+ let decision = 'Deep';
177
+ if (options.EdgePolicy) {
178
+ const candidate = {
179
+ Kind: edgeKind,
180
+ SourceEntityName: parentNode.EntityName,
181
+ SourceKey: parentNode.RecordKey,
182
+ SourceRecordData: parentNode.RecordData,
183
+ TargetEntityName: rel.ChildEntityInfo.Name,
184
+ TargetKey: childKey,
185
+ JoinField: rel.ChildJoinField,
186
+ Relationship: rel.RelationshipInfo,
187
+ CollectionName: rel.CollectionName,
188
+ IsSoftLink: false,
189
+ Depth: parentNode.Depth + 1,
190
+ HierarchyDirection: isHierarchyRel ? 'Down' : undefined,
191
+ };
192
+ decision = options.EdgePolicy(candidate);
193
+ if (decision === 'Skip')
194
+ continue;
195
+ }
196
+ const childNode = this.registerNode(rel.ChildEntityInfo, childData, rel.RelationshipInfo, parentNode, visited, stats, discoveringEdge);
197
+ if (!childNode)
198
+ continue;
199
+ if (decision === 'Deep') {
200
+ // Push child entity type, recurse with FULL mode, then pop
201
+ const alreadyInStack = ancestorStack.has(rel.ChildEntityInfo.Name);
202
+ if (!alreadyInStack) {
203
+ ancestorStack.add(rel.ChildEntityInfo.Name);
204
+ }
205
+ await this.walkChildren(childNode, 'full', options, visited, ancestorStack, stats, contextUser);
206
+ if (!alreadyInStack) {
207
+ ancestorStack.delete(rel.ChildEntityInfo.Name);
208
+ }
209
+ }
210
+ }
211
+ }
212
+ }
213
+ // =========================================================================
214
+ // Inbound Non-Curated FK Walking
215
+ // =========================================================================
216
+ /**
217
+ * Discovers inbound foreign keys that are not registered as curated EntityRelationships.
218
+ * Evaluated when ListNonCuratedInbound is true.
219
+ */
220
+ async walkNonCuratedInbound(parentNode, options, visited, ancestorStack, stats, contextUser) {
221
+ if (!options.EdgePolicy)
222
+ return; // Non-curated inbound edges require an explicit policy
223
+ const md = this.ProviderToUse;
224
+ const parentEntity = parentNode.EntityInfo;
225
+ const parentKeyField = parentEntity.FirstPrimaryKey.Name; // first-pk-ok: foreign keys target single-column primary keys
226
+ const parentKeyValue = parentNode.RecordData[parentKeyField];
227
+ if (parentKeyValue == null)
228
+ return;
229
+ // Curated child entity IDs to skip
230
+ const curatedChildEntityIds = new Set(parentEntity.RelatedEntities.map(r => NormalizeUUID(r.RelatedEntityID)));
231
+ for (const entity of md.Entities) {
232
+ if (options.RequireTrackRecordChanges && !entity.TrackRecordChanges)
233
+ continue;
234
+ if (this.shouldSkipEntity(entity.Name, options))
235
+ continue;
236
+ if (curatedChildEntityIds.has(NormalizeUUID(entity.ID)))
237
+ continue;
238
+ for (const field of entity.Fields) {
239
+ if (!field.RelatedEntityID || !UUIDsEqual(field.RelatedEntityID, parentEntity.ID))
240
+ continue;
241
+ if (this.isSystemFKField(field.Name))
242
+ continue;
243
+ const candidate = {
244
+ Kind: 'InboundFK',
245
+ SourceEntityName: parentNode.EntityName,
246
+ SourceKey: parentNode.RecordKey,
247
+ SourceRecordData: parentNode.RecordData,
248
+ TargetEntityName: entity.Name,
249
+ JoinField: field.Name,
250
+ Relationship: null,
251
+ IsSoftLink: false,
252
+ Depth: parentNode.Depth + 1,
253
+ };
254
+ const decision = options.EdgePolicy(candidate);
255
+ if (decision === 'Skip')
256
+ continue;
257
+ // Load records matching inbound FK
258
+ const filter = `[${field.Name}] = '${EscapeSQLString(String(parentKeyValue))}'` +
259
+ (!options.IncludeDeleted && this.entityHasSoftDelete(entity) ? ' AND __mj_DeletedAt IS NULL' : '');
260
+ try {
261
+ const rv = RunView.FromMetadataProvider(this.ProviderToUse);
262
+ const result = await rv.RunView({
263
+ EntityName: entity.Name,
264
+ ExtraFilter: filter,
265
+ ResultType: 'simple',
266
+ }, contextUser);
267
+ if (!result.Success) {
268
+ options.OnLoadFailure?.({ Edge: candidate, Decision: decision, Message: result.ErrorMessage || 'the query failed' });
269
+ continue;
270
+ }
271
+ for (const row of result.Results) {
272
+ const rowKey = BuildCompositeKeyFromRecord(entity, row);
273
+ const discoveringEdge = {
274
+ Kind: 'InboundFK',
275
+ FromKey: this.visitKey(parentNode.EntityName, parentNode.RecordID),
276
+ ToKey: this.visitKey(entity.Name, rowKey.ToConcatenatedString()),
277
+ SourceEntityName: parentNode.EntityName,
278
+ TargetEntityName: entity.Name,
279
+ JoinField: field.Name,
280
+ Relationship: null,
281
+ IsSoftLink: false,
282
+ };
283
+ const childNode = this.registerNode(entity, row, null, parentNode, visited, stats, discoveringEdge);
284
+ if (!childNode)
285
+ continue;
286
+ if (decision === 'Deep') {
287
+ ancestorStack.add(entity.Name);
288
+ await this.walkChildren(childNode, 'full', options, visited, ancestorStack, stats, contextUser);
289
+ ancestorStack.delete(entity.Name);
290
+ }
291
+ }
292
+ }
293
+ catch (e) {
294
+ const msg = e instanceof Error ? e.message : String(e);
295
+ LogError(`DependencyGraphWalker: Error loading inbound FK records for ${entity.Name}: ${msg}`);
296
+ options.OnLoadFailure?.({ Edge: candidate, Decision: decision, Message: msg });
297
+ }
298
+ }
299
+ }
300
+ }
301
+ // =========================================================================
302
+ // Soft Link Walking
303
+ // =========================================================================
304
+ /**
305
+ * Discovers records pointing to the parent via polymorphic EntityID/RecordID soft links.
306
+ */
307
+ async walkSoftLinks(parentNode, options, visited, ancestorStack, stats, contextUser) {
308
+ const md = this.ProviderToUse;
309
+ const parentEntity = parentNode.EntityInfo;
310
+ for (const entity of md.Entities) {
311
+ if (options.RequireTrackRecordChanges && !entity.TrackRecordChanges)
312
+ continue;
313
+ if (this.shouldSkipEntity(entity.Name, options))
314
+ continue;
315
+ // Look for fields declared with EntityIDFieldName
316
+ for (const field of entity.Fields) {
317
+ if (!field.EntityIDFieldName)
318
+ continue;
319
+ const candidate = {
320
+ Kind: 'SoftLink',
321
+ SourceEntityName: parentNode.EntityName,
322
+ SourceKey: parentNode.RecordKey,
323
+ SourceRecordData: parentNode.RecordData,
324
+ TargetEntityName: entity.Name,
325
+ JoinField: field.Name,
326
+ Relationship: null,
327
+ IsSoftLink: true,
328
+ EntityIDFieldName: field.EntityIDFieldName,
329
+ Depth: parentNode.Depth + 1,
330
+ };
331
+ const decision = options.EdgePolicy ? options.EdgePolicy(candidate) : 'Skip';
332
+ if (decision === 'Skip')
333
+ continue;
334
+ // Build query: EntityID discriminator match and RecordID match
335
+ // Soft links store either the full record-id ("ID|abc") or, commonly for a single
336
+ // key column, the bare value ("abc"); match both.
337
+ const recordIDValue = parentNode.RecordKey.ToRecordID();
338
+ const bareIDValue = parentNode.RecordKey.ToCompactURLSegment();
339
+ const filter = `([${field.EntityIDFieldName}] = '${EscapeSQLString(parentEntity.ID)}' OR [${field.EntityIDFieldName}] = '${EscapeSQLString(parentEntity.Name)}') ` +
340
+ `AND ([${field.Name}] = '${EscapeSQLString(recordIDValue)}' OR [${field.Name}] = '${EscapeSQLString(bareIDValue)}')` +
341
+ (!options.IncludeDeleted && this.entityHasSoftDelete(entity) ? ' AND __mj_DeletedAt IS NULL' : '');
342
+ try {
343
+ const rv = RunView.FromMetadataProvider(this.ProviderToUse);
344
+ const result = await rv.RunView({
345
+ EntityName: entity.Name,
346
+ ExtraFilter: filter,
347
+ ResultType: 'simple',
348
+ }, contextUser);
349
+ if (!result.Success) {
350
+ options.OnLoadFailure?.({ Edge: candidate, Decision: decision, Message: result.ErrorMessage || 'the query failed' });
351
+ continue;
352
+ }
353
+ for (const row of result.Results) {
354
+ const rowKey = BuildCompositeKeyFromRecord(entity, row);
355
+ const discoveringEdge = {
356
+ Kind: 'SoftLink',
357
+ FromKey: this.visitKey(parentNode.EntityName, parentNode.RecordID),
358
+ ToKey: this.visitKey(entity.Name, rowKey.ToConcatenatedString()),
359
+ SourceEntityName: parentNode.EntityName,
360
+ TargetEntityName: entity.Name,
361
+ JoinField: field.Name,
362
+ Relationship: null,
363
+ IsSoftLink: true,
364
+ EntityIDFieldName: field.EntityIDFieldName,
365
+ };
366
+ const childNode = this.registerNode(entity, row, null, parentNode, visited, stats, discoveringEdge);
367
+ if (!childNode)
368
+ continue;
369
+ if (decision === 'Deep') {
370
+ ancestorStack.add(entity.Name);
371
+ await this.walkChildren(childNode, 'full', options, visited, ancestorStack, stats, contextUser);
372
+ ancestorStack.delete(entity.Name);
373
+ }
374
+ }
375
+ }
376
+ catch (e) {
377
+ const msg = e instanceof Error ? e.message : String(e);
378
+ LogError(`DependencyGraphWalker: Error loading soft link records for ${entity.Name}: ${msg}`);
379
+ options.OnLoadFailure?.({ Edge: candidate, Decision: decision, Message: msg });
380
+ }
381
+ }
382
+ }
383
+ }
384
+ // =========================================================================
385
+ // Subtypes Walking (IS-A)
386
+ // =========================================================================
387
+ /**
388
+ * Discover and attach IS-A subtype rows for a given parent record.
389
+ */
390
+ async walkSubtypes(parentNode, options, visited, ancestorStack, stats, contextUser) {
391
+ const md = this.ProviderToUse;
392
+ if (!md.FindISAChildEntities)
393
+ return;
394
+ try {
395
+ // IS-A tables share one single-column key, and the provider matches on its bare value
396
+ // (not the "ID|<guid>" record-id string, which errors on a UUID column).
397
+ const pkValue = parentNode.RecordKey.KeyValuePairs[0]?.Value;
398
+ if (pkValue === null || pkValue === undefined)
399
+ return;
400
+ const subtypes = await md.FindISAChildEntities(parentNode.EntityInfo, String(pkValue), contextUser);
401
+ for (const subtype of subtypes) {
402
+ const childEntityInfo = md.EntityByName(subtype.ChildEntityName);
403
+ if (!childEntityInfo)
404
+ continue;
405
+ if (options.RequireTrackRecordChanges && !childEntityInfo.TrackRecordChanges)
406
+ continue;
407
+ if (this.shouldSkipEntity(childEntityInfo.Name, options))
408
+ continue;
409
+ const candidate = {
410
+ Kind: 'IsASubtype',
411
+ SourceEntityName: parentNode.EntityName,
412
+ SourceKey: parentNode.RecordKey,
413
+ SourceRecordData: parentNode.RecordData,
414
+ TargetEntityName: childEntityInfo.Name,
415
+ TargetKey: parentNode.RecordKey,
416
+ JoinField: childEntityInfo.FirstPrimaryKey.Name, // first-pk-ok: IS-A subtype shares the parent's single-column key
417
+ Relationship: null,
418
+ IsSoftLink: false,
419
+ Depth: parentNode.Depth + 1,
420
+ };
421
+ const decision = options.EdgePolicy ? options.EdgePolicy(candidate) : 'Deep';
422
+ if (decision === 'Skip')
423
+ continue;
424
+ // Load subtype record using the same primary key value
425
+ const childData = await this.loadRecordData(childEntityInfo, parentNode.RecordKey, contextUser);
426
+ if (!childData || Object.keys(childData).length === 0)
427
+ continue;
428
+ const discoveringEdge = {
429
+ Kind: 'IsASubtype',
430
+ FromKey: this.visitKey(parentNode.EntityName, parentNode.RecordID),
431
+ ToKey: this.visitKey(childEntityInfo.Name, parentNode.RecordID),
432
+ SourceEntityName: parentNode.EntityName,
433
+ TargetEntityName: childEntityInfo.Name,
434
+ JoinField: childEntityInfo.FirstPrimaryKey.Name, // first-pk-ok: IS-A subtype shares the parent's single-column key
435
+ Relationship: null,
436
+ IsSoftLink: false,
437
+ };
438
+ const childNode = this.registerNode(childEntityInfo, childData, null, parentNode, visited, stats, discoveringEdge, true);
439
+ if (!childNode)
440
+ continue;
441
+ if (decision === 'Deep') {
442
+ ancestorStack.add(childEntityInfo.Name);
443
+ await this.walkChildren(childNode, 'full', options, visited, ancestorStack, stats, contextUser);
444
+ ancestorStack.delete(childEntityInfo.Name);
445
+ }
446
+ }
447
+ }
448
+ catch (e) {
449
+ const msg = e instanceof Error ? e.message : String(e);
450
+ LogError(`DependencyGraphWalker: Error finding IS-A child entities for ${parentNode.EntityName}: ${msg}`);
451
+ }
452
+ }
453
+ // =========================================================================
454
+ // Forward Walking (FK field-driven)
455
+ // =========================================================================
456
+ /**
457
+ * Walk forward FK references: for each FK field on the parent entity that
458
+ * points to another entity, load the referenced record and recurse.
459
+ */
460
+ async walkForwardReferences(parentNode, options, visited, ancestorStack, stats, contextUser) {
461
+ const forwardRefs = this.discoverForwardReferences(parentNode.EntityInfo, options);
462
+ for (const ref of forwardRefs) {
463
+ // Self-pointer check
464
+ if (ref.IsSelf) {
465
+ if (ref.IsHierarchy && options.FollowHierarchies) {
466
+ // Follow hierarchy edge
467
+ const fkValue = parentNode.RecordData[ref.FKFieldName];
468
+ if (fkValue == null)
469
+ continue;
470
+ const targetKey = new CompositeKey([{
471
+ FieldName: ref.TargetKeyField,
472
+ Value: fkValue,
473
+ }]);
474
+ const candidate = {
475
+ Kind: 'Hierarchy',
476
+ SourceEntityName: parentNode.EntityName,
477
+ SourceKey: parentNode.RecordKey,
478
+ SourceRecordData: parentNode.RecordData,
479
+ TargetEntityName: ref.TargetEntityInfo.Name,
480
+ TargetKey: targetKey,
481
+ JoinField: ref.FKFieldName,
482
+ Relationship: null,
483
+ IsSoftLink: false,
484
+ Depth: parentNode.Depth + 1,
485
+ HierarchyDirection: 'Up',
486
+ };
487
+ const decision = options.EdgePolicy ? options.EdgePolicy(candidate) : 'Deep';
488
+ if (decision === 'Skip')
489
+ continue;
490
+ const vKey = this.visitKey(ref.TargetEntityInfo.Name, targetKey.ToConcatenatedString());
491
+ if (visited.has(vKey)) {
492
+ stats.VisitedSkips++;
493
+ continue;
494
+ }
495
+ const targetData = await this.loadRecordData(ref.TargetEntityInfo, targetKey, contextUser);
496
+ if (!targetData || Object.keys(targetData).length === 0)
497
+ continue;
498
+ const discoveringEdge = {
499
+ Kind: 'Hierarchy',
500
+ FromKey: this.visitKey(parentNode.EntityName, parentNode.RecordID),
501
+ ToKey: vKey,
502
+ SourceEntityName: parentNode.EntityName,
503
+ TargetEntityName: ref.TargetEntityInfo.Name,
504
+ JoinField: ref.FKFieldName,
505
+ Relationship: null,
506
+ IsSoftLink: false,
507
+ };
508
+ const targetNode = this.registerNode(ref.TargetEntityInfo, targetData, null, parentNode, visited, stats, discoveringEdge);
509
+ if (!targetNode)
510
+ continue;
511
+ if (decision === 'Deep') {
512
+ await this.walkChildren(targetNode, 'full', options, visited, ancestorStack, stats, contextUser);
513
+ }
514
+ }
515
+ // Plain self pointers are never followed automatically
516
+ continue;
517
+ }
518
+ // Ancestor check: skip if target entity type is on our current path
519
+ if (ancestorStack.has(ref.TargetEntityInfo.Name)) {
520
+ stats.AncestorSkips++;
521
+ continue;
522
+ }
523
+ if (this.shouldSkipEntity(ref.TargetEntityInfo.Name, options)) {
524
+ continue;
525
+ }
526
+ // Read the FK value from the record data
527
+ const fkValue = parentNode.RecordData[ref.FKFieldName];
528
+ if (fkValue == null)
529
+ continue;
530
+ const targetKey = new CompositeKey([{
531
+ FieldName: ref.TargetKeyField,
532
+ Value: fkValue,
533
+ }]);
534
+ const edgeKind = ref.IsEmbedded ? 'Embedded' : 'ForwardFK';
535
+ // Check edge policy before loading
536
+ let decision = ref.IsEmbedded ? 'Deep' : 'Reference';
537
+ if (options.EdgePolicy) {
538
+ const candidate = {
539
+ Kind: edgeKind,
540
+ SourceEntityName: parentNode.EntityName,
541
+ SourceKey: parentNode.RecordKey,
542
+ SourceRecordData: parentNode.RecordData,
543
+ TargetEntityName: ref.TargetEntityInfo.Name,
544
+ TargetKey: targetKey,
545
+ JoinField: ref.FKFieldName,
546
+ Relationship: null,
547
+ IsSoftLink: false,
548
+ Depth: parentNode.Depth + 1,
549
+ };
550
+ decision = options.EdgePolicy(candidate);
551
+ if (decision === 'Skip')
552
+ continue;
553
+ }
554
+ // Skip if we've already visited this specific record
555
+ const vKey = this.visitKey(ref.TargetEntityInfo.Name, targetKey.ToConcatenatedString());
556
+ if (visited.has(vKey)) {
557
+ stats.VisitedSkips++;
558
+ continue;
559
+ }
560
+ const targetData = await this.loadRecordData(ref.TargetEntityInfo, targetKey, contextUser);
561
+ if (!targetData || Object.keys(targetData).length === 0)
562
+ continue;
563
+ const discoveringEdge = {
564
+ Kind: edgeKind,
565
+ FromKey: this.visitKey(parentNode.EntityName, parentNode.RecordID),
566
+ ToKey: vKey,
567
+ SourceEntityName: parentNode.EntityName,
568
+ TargetEntityName: ref.TargetEntityInfo.Name,
569
+ JoinField: ref.FKFieldName,
570
+ Relationship: null,
571
+ IsSoftLink: false,
572
+ };
573
+ const targetNode = this.registerNode(ref.TargetEntityInfo, targetData, null, parentNode, visited, stats, discoveringEdge);
574
+ if (!targetNode)
575
+ continue;
576
+ // Recurse based on policy decision:
577
+ // 'Deep' recurses with full mode; 'Reference' recurses with forward-only mode
578
+ const nextMode = decision === 'Deep' ? 'full' : 'forward-only';
579
+ ancestorStack.add(ref.TargetEntityInfo.Name);
580
+ await this.walkChildren(targetNode, nextMode, options, visited, ancestorStack, stats, contextUser);
581
+ ancestorStack.delete(ref.TargetEntityInfo.Name);
582
+ }
583
+ }
584
+ // =========================================================================
585
+ // Relationship Discovery
586
+ // =========================================================================
587
+ /**
588
+ * Discover reverse relationships for an entity using EntityRelationship metadata.
589
+ */
590
+ discoverReverseRelationships(parentEntity, options) {
591
+ const cached = this.reverseRelCache.get(parentEntity.ID);
592
+ if (cached)
593
+ return cached;
594
+ const md = this.ProviderToUse;
595
+ const relationships = [];
596
+ for (const rel of parentEntity.RelatedEntities) {
597
+ // Only walk One-To-Many (parent has many children)
598
+ if (rel.Type.trim() !== 'One To Many')
599
+ continue;
600
+ const childEntity = md.EntityByID(rel.RelatedEntityID);
601
+ if (!childEntity)
602
+ continue;
603
+ if (options.RequireTrackRecordChanges && !childEntity.TrackRecordChanges)
604
+ continue;
605
+ const parentKeyField = this.resolveParentKeyFieldFromRelationship(rel, parentEntity);
606
+ let collectionName;
607
+ let isCollection = false;
608
+ if (rel.RelatedRecordCollection) {
609
+ isCollection = true;
610
+ try {
611
+ const parsed = typeof rel.RelatedRecordCollection === 'string'
612
+ ? JSON.parse(rel.RelatedRecordCollection)
613
+ : rel.RelatedRecordCollection;
614
+ if (parsed && typeof parsed['Name'] === 'string') {
615
+ collectionName = parsed['Name'];
616
+ }
617
+ }
618
+ catch {
619
+ // Ignore parse error; keep isCollection = true
620
+ }
621
+ }
622
+ relationships.push({
623
+ ChildEntityInfo: childEntity,
624
+ ChildJoinField: rel.RelatedEntityJoinField,
625
+ ParentKeyField: parentKeyField,
626
+ RelationshipInfo: rel,
627
+ CollectionName: collectionName,
628
+ IsCollection: isCollection,
629
+ });
630
+ }
631
+ this.reverseRelCache.set(parentEntity.ID, relationships);
632
+ return relationships;
633
+ }
634
+ /**
635
+ * Discover forward FK references on an entity.
636
+ */
637
+ discoverForwardReferences(entity, options) {
638
+ const cached = this.forwardRefCache.get(entity.ID);
639
+ if (cached)
640
+ return cached;
641
+ const md = this.ProviderToUse;
642
+ const refs = [];
643
+ for (const field of entity.Fields) {
644
+ if (!field.RelatedEntityID)
645
+ continue;
646
+ if (this.isSystemFKField(field.Name))
647
+ continue;
648
+ const isSelf = UUIDsEqual(field.RelatedEntityID, entity.ID);
649
+ const targetEntity = isSelf ? entity : md.EntityByID(field.RelatedEntityID);
650
+ if (!targetEntity)
651
+ continue;
652
+ if (options.RequireTrackRecordChanges && !targetEntity.TrackRecordChanges)
653
+ continue;
654
+ const isHierarchy = field.IsHierarchy;
655
+ refs.push({
656
+ TargetEntityInfo: targetEntity,
657
+ FKFieldName: field.Name,
658
+ TargetKeyField: this.resolveParentKeyFieldFromFK(field, targetEntity),
659
+ IsSelf: isSelf,
660
+ IsHierarchy: isHierarchy,
661
+ IsEmbedded: false, // Embedded records are declared in subclasses or metadata
662
+ });
663
+ }
664
+ this.forwardRefCache.set(entity.ID, refs);
665
+ return refs;
666
+ }
667
+ // =========================================================================
668
+ // Data Loading
669
+ // =========================================================================
670
+ /**
671
+ * Load child records for a reverse relationship.
672
+ */
673
+ async loadChildRecords(parentNode, rel, options, contextUser, onFailure) {
674
+ const parentKeyValue = parentNode.RecordData[rel.ParentKeyField] ?? null;
675
+ if (parentKeyValue == null)
676
+ return [];
677
+ let extraFilter = `[${rel.ChildJoinField}] = '${EscapeSQLString(String(parentKeyValue))}'`;
678
+ if (!options.IncludeDeleted && this.entityHasSoftDelete(rel.ChildEntityInfo)) {
679
+ extraFilter += ` AND __mj_DeletedAt IS NULL`;
680
+ }
681
+ try {
682
+ const rv = RunView.FromMetadataProvider(this.ProviderToUse);
683
+ const result = await rv.RunView({
684
+ EntityName: rel.ChildEntityInfo.Name,
685
+ ExtraFilter: extraFilter,
686
+ ResultType: 'simple',
687
+ }, contextUser);
688
+ if (!result.Success) {
689
+ LogError(`DependencyGraphWalker: Failed to load children of ` +
690
+ `${parentNode.EntityName} via ${rel.ChildEntityInfo.Name}.${rel.ChildJoinField}: ` +
691
+ `${result.ErrorMessage}`);
692
+ onFailure(result.ErrorMessage || 'the query failed');
693
+ return [];
694
+ }
695
+ return result.Results;
696
+ }
697
+ catch (e) {
698
+ const msg = e instanceof Error ? e.message : String(e);
699
+ LogError(`DependencyGraphWalker: Error loading child records: ${msg}`);
700
+ onFailure(msg);
701
+ return [];
702
+ }
703
+ }
704
+ /**
705
+ * Load a single record's data by primary key.
706
+ */
707
+ async loadRecordData(entityInfo, key, contextUser) {
708
+ const rv = RunView.FromMetadataProvider(this.ProviderToUse);
709
+ const result = await rv.RunView({
710
+ EntityName: entityInfo.Name,
711
+ ExtraFilter: key.ToWhereClause(),
712
+ ResultType: 'simple',
713
+ }, contextUser);
714
+ if (!result.Success || result.Results.length === 0) {
715
+ LogError(`DependencyGraphWalker: loadRecordData returned empty for entity ` +
716
+ `'${entityInfo.Name}' with key ${key.ToConcatenatedString()}`);
717
+ return {};
718
+ }
719
+ return result.Results[0];
720
+ }
721
+ // =========================================================================
722
+ // Node Registration
723
+ // =========================================================================
724
+ /**
725
+ * Build a DependencyNode from record data, register it in the visited set,
726
+ * and attach it as a child of the parent. Returns null if already visited.
727
+ */
728
+ registerNode(entityInfo, recordData, relationship, parentNode, visited, stats, discoveringEdge, isSubtypeRow) {
729
+ const key = BuildCompositeKeyFromRecord(entityInfo, recordData);
730
+ const vKey = this.visitKey(entityInfo.Name, key.ToConcatenatedString());
731
+ if (visited.has(vKey)) {
732
+ stats.VisitedSkips++;
733
+ return null;
734
+ }
735
+ visited.add(vKey);
736
+ const node = {
737
+ EntityName: entityInfo.Name,
738
+ EntityInfo: entityInfo,
739
+ RecordKey: key,
740
+ RecordID: key.ToConcatenatedString(),
741
+ RecordData: recordData,
742
+ Relationship: relationship,
743
+ Children: [],
744
+ Depth: parentNode.Depth + 1,
745
+ DiscoveringEdge: discoveringEdge ?? null,
746
+ IsSubtypeRow: isSubtypeRow ?? false,
747
+ };
748
+ parentNode.Children.push(node);
749
+ this.incrementEntityCount(stats, entityInfo.Name);
750
+ return node;
751
+ }
752
+ // =========================================================================
753
+ // Logging
754
+ // =========================================================================
755
+ /** Create an empty stats accumulator. */
756
+ createEmptyStats() {
757
+ return {
758
+ EntityCounts: new Map(),
759
+ TotalRecords: 0,
760
+ AncestorSkips: 0,
761
+ VisitedSkips: 0,
762
+ ForwardOnlySuppressions: 0,
763
+ };
764
+ }
765
+ /** Increment the per-entity record counter. */
766
+ incrementEntityCount(stats, entityName) {
767
+ stats.TotalRecords++;
768
+ stats.EntityCounts.set(entityName, (stats.EntityCounts.get(entityName) ?? 0) + 1);
769
+ }
770
+ /**
771
+ * Log a detailed summary of the walk.
772
+ */
773
+ logWalkSummary(rootEntityName, options, stats) {
774
+ const entityBreakdown = Array.from(stats.EntityCounts.entries())
775
+ .sort((a, b) => b[1] - a[1])
776
+ .map(([name, count]) => ` ${name}: ${count}`)
777
+ .join('\n');
778
+ LogStatus(`DependencyGraphWalker: Walk complete for '${rootEntityName}'\n` +
779
+ ` Total records: ${stats.TotalRecords}\n` +
780
+ ` Unique entities: ${stats.EntityCounts.size}\n` +
781
+ ` Ancestor skips: ${stats.AncestorSkips}\n` +
782
+ ` Visited skips: ${stats.VisitedSkips}\n` +
783
+ ` Forward-only suppressions: ${stats.ForwardOnlySuppressions}\n` +
784
+ ` Max depth: ${options.MaxDepth}\n` +
785
+ ` Per-entity breakdown:\n${entityBreakdown}`);
786
+ }
787
+ // =========================================================================
788
+ // Helpers
789
+ // =========================================================================
790
+ /** Build a unique key for the visited set: "EntityName::recordID" */
791
+ visitKey(entityName, recordId) {
792
+ return `${entityName}::${recordId}`;
793
+ }
794
+ /**
795
+ * Determine the parent key field from an EntityRelationship record.
796
+ */
797
+ resolveParentKeyFieldFromRelationship(rel, parentEntity) {
798
+ if (rel.EntityKeyField && rel.EntityKeyField.trim().length > 0) {
799
+ return rel.EntityKeyField;
800
+ }
801
+ return parentEntity.FirstPrimaryKey.Name; // first-pk-ok: FK target — relationship without explicit EntityKeyField joins on parent single key column
802
+ }
803
+ /**
804
+ * Determine the target key field from a FK field definition.
805
+ */
806
+ resolveParentKeyFieldFromFK(fkField, targetEntity) {
807
+ if (fkField.RelatedEntityFieldName && fkField.RelatedEntityFieldName.trim().length > 0) {
808
+ return fkField.RelatedEntityFieldName;
809
+ }
810
+ return targetEntity.FirstPrimaryKey.Name; // first-pk-ok: FK target — column without RelatedEntityFieldName references target single key column
811
+ }
812
+ /** Check if a field name matches a system/infrastructure FK pattern. */
813
+ isSystemFKField(fieldName) {
814
+ return SYSTEM_FK_SKIP_PATTERNS.some(pattern => pattern.test(fieldName));
815
+ }
816
+ /** Check if an entity should be skipped based on filter options. */
817
+ shouldSkipEntity(entityName, options) {
818
+ if (options.ExcludeEntities.length > 0 && options.ExcludeEntities.includes(entityName)) {
819
+ return true;
820
+ }
821
+ if (options.EntityFilter.length > 0 && !options.EntityFilter.includes(entityName)) {
822
+ return true;
823
+ }
824
+ return false;
825
+ }
826
+ /** Check if an entity has a soft delete field. */
827
+ entityHasSoftDelete(entityInfo) {
828
+ return entityInfo.Fields.some(f => f.Name === '__mj_DeletedAt');
829
+ }
830
+ /** Apply defaults to walk options. */
831
+ resolveDefaults(options) {
832
+ return {
833
+ MaxDepth: options.MaxDepth ?? 10,
834
+ EntityFilter: options.EntityFilter ?? [],
835
+ ExcludeEntities: options.ExcludeEntities ?? [],
836
+ IncludeDeleted: options.IncludeDeleted ?? false,
837
+ RequireTrackRecordChanges: options.RequireTrackRecordChanges ?? true,
838
+ IncludeSoftLinks: options.IncludeSoftLinks ?? false,
839
+ IncludeSubtypes: options.IncludeSubtypes ?? false,
840
+ FollowHierarchies: options.FollowHierarchies ?? false,
841
+ ListNonCuratedInbound: options.ListNonCuratedInbound ?? false,
842
+ EdgePolicy: options.EdgePolicy,
843
+ OnLoadFailure: options.OnLoadFailure,
844
+ };
845
+ }
846
+ }
847
+ //# sourceMappingURL=DependencyGraphWalker.js.map