@memberjunction/record-cloning 0.0.1-seed.1 → 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.
Files changed (51) hide show
  1. package/LICENSE +183 -0
  2. package/README.md +98 -3
  3. package/dist/CloneAuthorization.d.ts +50 -0
  4. package/dist/CloneAuthorization.d.ts.map +1 -0
  5. package/dist/CloneAuthorization.js +75 -0
  6. package/dist/CloneAuthorization.js.map +1 -0
  7. package/dist/CloneExecutor.d.ts +50 -0
  8. package/dist/CloneExecutor.d.ts.map +1 -0
  9. package/dist/CloneExecutor.js +350 -0
  10. package/dist/CloneExecutor.js.map +1 -0
  11. package/dist/CloneKeys.d.ts +65 -0
  12. package/dist/CloneKeys.d.ts.map +1 -0
  13. package/dist/CloneKeys.js +118 -0
  14. package/dist/CloneKeys.js.map +1 -0
  15. package/dist/CloneMaterializer.d.ts +52 -0
  16. package/dist/CloneMaterializer.d.ts.map +1 -0
  17. package/dist/CloneMaterializer.js +267 -0
  18. package/dist/CloneMaterializer.js.map +1 -0
  19. package/dist/ClonePlanHash.d.ts +11 -0
  20. package/dist/ClonePlanHash.d.ts.map +1 -0
  21. package/dist/ClonePlanHash.js +16 -0
  22. package/dist/ClonePlanHash.js.map +1 -0
  23. package/dist/ClonePlanner.d.ts +33 -0
  24. package/dist/ClonePlanner.d.ts.map +1 -0
  25. package/dist/ClonePlanner.js +870 -0
  26. package/dist/ClonePlanner.js.map +1 -0
  27. package/dist/CloneRecordProcessor.d.ts +28 -0
  28. package/dist/CloneRecordProcessor.d.ts.map +1 -0
  29. package/dist/CloneRecordProcessor.js +101 -0
  30. package/dist/CloneRecordProcessor.js.map +1 -0
  31. package/dist/RecordCloneEngine.d.ts +35 -0
  32. package/dist/RecordCloneEngine.d.ts.map +1 -0
  33. package/dist/RecordCloneEngine.js +60 -0
  34. package/dist/RecordCloneEngine.js.map +1 -0
  35. package/dist/RecordCloningStartup.d.ts +14 -0
  36. package/dist/RecordCloningStartup.d.ts.map +1 -0
  37. package/dist/RecordCloningStartup.js +32 -0
  38. package/dist/RecordCloningStartup.js.map +1 -0
  39. package/dist/__tests__/helpers/cloneAuthorizations.d.ts +7 -0
  40. package/dist/__tests__/helpers/cloneAuthorizations.d.ts.map +1 -0
  41. package/dist/__tests__/helpers/cloneAuthorizations.js +17 -0
  42. package/dist/__tests__/helpers/cloneAuthorizations.js.map +1 -0
  43. package/dist/index.d.ts +15 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +15 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/operations.d.ts +64 -0
  48. package/dist/operations.d.ts.map +1 -0
  49. package/dist/operations.js +390 -0
  50. package/dist/operations.js.map +1 -0
  51. package/package.json +29 -5
@@ -0,0 +1,870 @@
1
+ /**
2
+ * @file ClonePlanner.ts
3
+ * Computes deterministic clone plans for entity records using graph traversal.
4
+ * Enforces caps, permissions, unique key classification, field transformations, and plan hashing.
5
+ * @see plans/record-cloning/README.md §5, §7, §13.1
6
+ */
7
+ import { CompositeKey, Metadata, RunView, } from '@memberjunction/core';
8
+ import { DependencyGraphWalker } from '@memberjunction/record-graph';
9
+ import { DetectIntraPlanCollisions, GenerateUUID, MapFieldsForClone, ResolveEdgePolicy, ResolveEffectiveCloneOptions, NormalizeClonePresets, FieldMetaFromEntity, CloneConfigMetaFromEntity, CloneConfigValidator, IsRenameField, NameCollisionPrefix, RowMatchesExclusion, BagEntry, IsEdgeFromRoot, } from '@memberjunction/record-cloning-base';
10
+ import { EscapeSQLString } from '@memberjunction/global';
11
+ import { CloneAuthorizer } from './CloneAuthorization.js';
12
+ import { ComputeClonePlanHash } from './ClonePlanHash.js';
13
+ import { DeriveTargetKey, IsUuidColumn, KeyStrategyFor, ToRecordKeyString } from './CloneKeys.js';
14
+ /**
15
+ * Fields the user may not read or may not supply on create, for entities with field-level security.
16
+ * The mapper leaves them out, so a clone never copies a value its user couldn't see or set.
17
+ */
18
+ function fieldLevelDenials(entity, user) {
19
+ if (!entity.EnableFieldLevelSecurity)
20
+ return undefined;
21
+ const deniedRead = entity.GetDeniedReadFields(user);
22
+ const deniedCreate = entity.GetDeniedCreateFields(user);
23
+ const names = (denied) => entity.Fields.filter((f) => denied.has(f.Name.toLowerCase())).map((f) => f.Name);
24
+ return { DeniedReadFields: names(deniedRead), DeniedCreateFields: names(deniedCreate) };
25
+ }
26
+ /** The requested root key as a record-id string, whatever shape the request used. */
27
+ function requestedKeyString(key) {
28
+ if (typeof key === 'string')
29
+ return ToRecordKeyString(key);
30
+ if (key && typeof key === 'object') {
31
+ if (Array.isArray(key.KeyValuePairs))
32
+ return ToRecordKeyString(key);
33
+ // The { Field: value } shorthand.
34
+ return ToRecordKeyString({ KeyValuePairs: Object.entries(key).map(([FieldName, Value]) => ({ FieldName, Value })) });
35
+ }
36
+ return '';
37
+ }
38
+ export class ClonePlanner {
39
+ constructor(options) {
40
+ this._provider = options?.Provider;
41
+ }
42
+ get Provider() {
43
+ return this._provider ?? Metadata.Provider;
44
+ }
45
+ /**
46
+ * Generates a fully calculated ClonePlan for the requested record.
47
+ */
48
+ async Plan(request, contextUser) {
49
+ const md = this.Provider;
50
+ const entityName = request.EntityName || request.Roots?.[0]?.EntityName;
51
+ if (!entityName) {
52
+ throw new Error(`RecordCloneRequest must specify EntityName or Roots.`);
53
+ }
54
+ const sourceRecordKey = request.SourceRecordKey || request.Roots?.[0]?.Key;
55
+ if (!sourceRecordKey) {
56
+ throw new Error(`RecordCloneRequest must specify SourceRecordKey or Roots.`);
57
+ }
58
+ const rootEntityInfo = md.EntityByName(entityName);
59
+ if (!rootEntityInfo) {
60
+ throw new Error(`Entity '${entityName}' not found in metadata.`);
61
+ }
62
+ const warnings = [];
63
+ let planBlocked = false;
64
+ const rootConfig = rootEntityInfo.CloneConfig ?? null;
65
+ const authorizer = new CloneAuthorizer(md);
66
+ // The configuration is checked here, not only in the Entities form: an invalid bag (a bad
67
+ // policy, a cap that isn't a positive integer, a rule on a missing field) blocks the plan.
68
+ if (rootConfig) {
69
+ const findings = CloneConfigValidator.Validate(CloneConfigMetaFromEntity(rootEntityInfo, { ...rootConfig, Enabled: true }), md.Entities.map((e) => ({ Name: e.Name, Fields: [] })));
70
+ for (const finding of findings) {
71
+ // A key for an entity reached through a descendant is valid configuration; the
72
+ // Entities form's Validate still notes it, but every plan needn't repeat it.
73
+ if (finding.Severity === 'Warning' && finding.PropertyPath.startsWith('Relationships['))
74
+ continue;
75
+ // A relationship key that matches nothing only means that edge isn't configured
76
+ // (a stale key, say): report it, but don't refuse every clone of the entity over it.
77
+ const blocking = finding.Severity === 'Error' && !finding.PropertyPath.startsWith('Relationships[');
78
+ warnings.push({
79
+ Code: 'CONFIG_INVALID',
80
+ Severity: blocking ? 'Error' : 'Warning',
81
+ Field: finding.PropertyPath,
82
+ Message: `Clone configuration for '${entityName}': ${finding.Message}`,
83
+ });
84
+ if (blocking)
85
+ planBlocked = true;
86
+ }
87
+ }
88
+ // A preset (Clone.Presets, looked up by Key) contributes options under the request's own
89
+ // options and edge policy overrides by related entity name.
90
+ const presetKey = request.Preset || request.Options?.Preset;
91
+ const preset = presetKey ? NormalizeClonePresets(rootConfig?.Presets).find((p) => p.Key === presetKey) : undefined;
92
+ if (presetKey && !preset) {
93
+ warnings.push({
94
+ Code: 'OPTION_OVERRIDE_IGNORED',
95
+ Severity: 'Warning',
96
+ Field: 'Preset',
97
+ Message: `Preset '${presetKey}' is not defined for '${entityName}'.`,
98
+ });
99
+ }
100
+ // A preset is configured by the entity's owner, so its options act as configuration (they may
101
+ // widen scope for anyone); only the request's own options go through the override rules.
102
+ const configWithPreset = preset?.Options ? { ...(rootConfig ?? {}), ...preset.Options } : rootConfig;
103
+ const presetEdgeOverrides = preset?.Relationships
104
+ ? Object.entries(preset.Relationships).map(([ChildEntityName, cfg]) => ({ ChildEntityName, Policy: cfg.Policy }))
105
+ : undefined;
106
+ // The entity's configuration supplies the defaults; UserEditable and the Fire Hooks
107
+ // authorization decide which request values may change them (plan §4.1, §9.4).
108
+ const resolved = ResolveEffectiveCloneOptions(configWithPreset, request.Options, {
109
+ CanFireHooks: authorizer.CanFireHooks(contextUser),
110
+ CanOverrideScope: authorizer.CanOverrideScope(contextUser),
111
+ });
112
+ const effectiveOptions = resolved.Options;
113
+ warnings.push(...resolved.Warnings);
114
+ // Per-edge request overrides follow the same rule as the scope options: Skip and
115
+ // Reference narrow the copy (allowed when UserEditable permits, or for an override
116
+ // holder); Deep widens it and needs Clone Records: Override Scope.
117
+ const canOverrideScope = authorizer.CanOverrideScope(contextUser);
118
+ const editable = rootConfig?.UserEditable ?? 'all';
119
+ const mayNarrowEdges = editable === 'scope' || editable === 'all' || canOverrideScope;
120
+ const edgeOverrides = (request.EdgeOverrides ?? []).filter((o) => {
121
+ if (o.Policy === 'Deep' ? canOverrideScope : mayNarrowEdges)
122
+ return true;
123
+ warnings.push({
124
+ Code: o.Policy === 'Deep' ? 'SCOPE_OVERRIDE_FORBIDDEN' : 'OPTION_OVERRIDE_IGNORED',
125
+ Severity: 'Warning',
126
+ Field: 'EdgeOverrides',
127
+ Message: o.Policy === 'Deep'
128
+ ? `Copying a relationship the configuration leaves out needs the 'Clone Records: Override Scope' authorization.`
129
+ : `Relationship overrides were ignored: this entity's clone configuration sets UserEditable to '${editable}'.`,
130
+ });
131
+ return false;
132
+ });
133
+ const maxDepth = effectiveOptions.MaxDepth;
134
+ const maxRecords = effectiveOptions.MaxRecords;
135
+ // An entity is a clone ROOT only when its configuration enables it (plan §4.1).
136
+ if (rootConfig?.Enabled !== true || rootConfig?.NotCloneable === true) {
137
+ warnings.push({
138
+ Code: 'NOT_CLONEABLE',
139
+ Severity: 'Error',
140
+ Message: rootConfig?.NotCloneableReason
141
+ || `Cloning is not enabled for '${entityName}'. Set Configuration.Clone.Enabled on its MJ: Entities record.`,
142
+ });
143
+ planBlocked = true;
144
+ }
145
+ const rootAuth = authorizer.CanCloneEntity(rootEntityInfo, contextUser);
146
+ if (!rootAuth.Granted) {
147
+ warnings.push({
148
+ Code: 'FORBIDDEN',
149
+ Severity: 'Error',
150
+ Message: `Cloning '${entityName}' requires the '${rootAuth.Name}' authorization.`,
151
+ });
152
+ planBlocked = true;
153
+ }
154
+ // Verify root authorization / RequiredUserType
155
+ if (rootConfig?.RequiredUserType && contextUser.Type?.trim() !== rootConfig.RequiredUserType?.trim()) {
156
+ warnings.push({
157
+ Code: 'REQUIRED_USER_TYPE_MISMATCH',
158
+ Severity: 'Error',
159
+ Message: `Cloning entity '${entityName}' requires user type '${rootConfig.RequiredUserType}', current user is '${contextUser.Type}'.`,
160
+ });
161
+ planBlocked = true;
162
+ }
163
+ const rootPerms = rootEntityInfo.GetUserPermisions(contextUser);
164
+ if (!rootPerms.CanCreate) {
165
+ warnings.push({
166
+ Code: 'NO_CREATE_PERMISSION',
167
+ Severity: 'Error',
168
+ Message: `User '${contextUser.Email || contextUser.Name}' lacks CanCreate permission on root entity '${entityName}'.`,
169
+ });
170
+ planBlocked = true;
171
+ }
172
+ // Refused before anything is planned: an empty plan that still names the requested record.
173
+ const refusedPlan = () => ({
174
+ PlanVersion: 1,
175
+ EffectiveOptions: effectiveOptions,
176
+ Hash: '',
177
+ // Name the requested record even though nothing was walked, so the refusal log says which.
178
+ Roots: [requestedKeyString(sourceRecordKey)],
179
+ RootEntityName: entityName,
180
+ RootSourceKey: requestedKeyString(sourceRecordKey),
181
+ RootTargetKey: '',
182
+ Nodes: [],
183
+ Edges: [],
184
+ Excluded: [],
185
+ Counts: {
186
+ ByEntity: { [entityName]: { Create: 0, Reference: 0, Skip: 0 } },
187
+ Create: 0,
188
+ Total: 0,
189
+ },
190
+ Warnings: warnings,
191
+ Blocked: true,
192
+ });
193
+ if (planBlocked)
194
+ return refusedPlan();
195
+ const key = new CompositeKey();
196
+ if (sourceRecordKey instanceof CompositeKey) {
197
+ key.KeyValuePairs = sourceRecordKey.KeyValuePairs.map((kvp) => ({
198
+ FieldName: kvp.FieldName,
199
+ Value: kvp.Value,
200
+ }));
201
+ }
202
+ else if (typeof sourceRecordKey === 'object' &&
203
+ sourceRecordKey !== null &&
204
+ 'KeyValuePairs' in sourceRecordKey &&
205
+ Array.isArray(sourceRecordKey.KeyValuePairs)) {
206
+ key.KeyValuePairs = sourceRecordKey.KeyValuePairs.map((kvp) => ({
207
+ FieldName: kvp.FieldName,
208
+ Value: kvp.Value,
209
+ }));
210
+ }
211
+ else if (typeof sourceRecordKey === 'string') {
212
+ key.LoadFromURLSegment(rootEntityInfo, sourceRecordKey);
213
+ }
214
+ else {
215
+ key.LoadFromEntityInfoAndRecord(rootEntityInfo, sourceRecordKey);
216
+ }
217
+ const walker = new DependencyGraphWalker(md);
218
+ const candidateEdges = [];
219
+ const excludedNodes = [];
220
+ // Traverse graph with EdgePolicy callback
221
+ const rootNode = await walker.WalkDependents(entityName, key, {
222
+ MaxDepth: maxDepth,
223
+ RequireTrackRecordChanges: false,
224
+ IncludeSubtypes: effectiveOptions.Subtypes === 'include',
225
+ IncludeSoftLinks: effectiveOptions.SoftLinks === 'include',
226
+ FollowHierarchies: effectiveOptions.Hierarchy === 'subtree',
227
+ // A copy missing rows it should have (a Flow agent without its steps) looks complete, so say so.
228
+ OnLoadFailure: (failure) => {
229
+ if (failure.Decision !== 'Deep')
230
+ return;
231
+ const edge = failure.Edge;
232
+ warnings.push({
233
+ Code: 'CHILD_ROWS_UNREADABLE',
234
+ Severity: 'Warning',
235
+ NodeKey: `${edge.SourceEntityName}::${edge.SourceKey.ToConcatenatedString()}`,
236
+ Message: `Couldn't read the ${edge.TargetEntityName} rows (by ${edge.JoinField}) of this ${edge.SourceEntityName} record, so the copy leaves them out: ${failure.Message}`,
237
+ });
238
+ },
239
+ EdgePolicy: (candidate) => {
240
+ const targetEntity = md.EntityByName(candidate.TargetEntityName);
241
+ const joinFieldInfo = targetEntity?.Fields.find((f) => f.Name.toLowerCase() === candidate.JoinField.toLowerCase());
242
+ // Check server-generated children on parent
243
+ const sourceEnt = md.EntityByName(candidate.SourceEntityName);
244
+ const parentConfig = sourceEnt?.CloneConfig ?? null;
245
+ if (parentConfig?.Hooks?.ServerGeneratedChildren?.includes(candidate.TargetEntityName)) {
246
+ warnings.push({
247
+ Code: 'SERVER_GENERATED_CHILD_SKIPPED',
248
+ Severity: 'Warning',
249
+ Message: `Relationship to '${candidate.TargetEntityName}' skipped because parent creates it in server Save() hooks.`,
250
+ });
251
+ excludedNodes.push({
252
+ EntityName: candidate.TargetEntityName,
253
+ SourceKey: candidate.TargetKey ? candidate.TargetKey.ToConcatenatedString() : '',
254
+ Reason: `Skipped because parent creates it in server Save() hooks.`,
255
+ });
256
+ return 'Skip';
257
+ }
258
+ const relPolicy = candidate.Relationship?.CloneConfig ?? undefined;
259
+ const targetConfig = targetEntity?.CloneConfig ?? null;
260
+ // Up a hierarchy is the parent the row hangs from: a pointer to keep, never a
261
+ // subtree to copy. Configuration keys name the downward relationship, so they don't apply.
262
+ const upward = candidate.Kind === 'Hierarchy' && candidate.HierarchyDirection === 'Up';
263
+ const resolution = upward ? { Policy: 'Reference', PolicySource: 'BuiltIn', Locked: true, Warnings: [] } : ResolveEdgePolicy({
264
+ FromKey: `${candidate.SourceEntityName}::${candidate.SourceKey.ToConcatenatedString()}`,
265
+ ToKey: `${candidate.TargetEntityName}::${candidate.TargetKey ? candidate.TargetKey.ToConcatenatedString() : 'pending'}`,
266
+ Kind: candidate.Kind,
267
+ ParentEntityName: candidate.SourceEntityName,
268
+ ChildEntityName: candidate.TargetEntityName,
269
+ RootEntityName: entityName,
270
+ JoinField: candidate.JoinField,
271
+ RelationshipID: candidate.Relationship?.ID,
272
+ RelationshipConfig: relPolicy,
273
+ // Enabled=false only means "not a clone root"; NotCloneable is what keeps a child out.
274
+ ChildEntityConfig: {
275
+ NotCloneable: targetConfig?.NotCloneable === true,
276
+ NotCloneableReason: targetConfig?.NotCloneableReason,
277
+ AllowCreateAPI: targetEntity?.AllowCreateAPI,
278
+ },
279
+ IsHierarchyField: joinFieldInfo?.IsHierarchy === true,
280
+ RootEntityConfig: rootConfig ? {
281
+ Relationships: rootConfig.Relationships,
282
+ } : undefined,
283
+ // Below the root, the parent entity's own configuration applies too.
284
+ ParentEntityConfig: candidate.SourceEntityName !== entityName && parentConfig?.Relationships
285
+ ? { Relationships: parentConfig.Relationships }
286
+ : undefined,
287
+ PresetConfig: presetEdgeOverrides ? {
288
+ EdgeOverrides: presetEdgeOverrides,
289
+ } : undefined,
290
+ RequestOverrides: edgeOverrides.map((o) => ({
291
+ RelationshipID: o.RelationshipID,
292
+ Policy: o.Policy,
293
+ })),
294
+ IsUniqueFK: joinFieldInfo?.IsUnique,
295
+ CurrentDepth: candidate.Depth,
296
+ MaxDepth: maxDepth,
297
+ });
298
+ warnings.push(...resolution.Warnings);
299
+ candidateEdges.push({
300
+ FromKey: `${candidate.SourceEntityName}::${candidate.SourceKey.ToConcatenatedString()}`,
301
+ ToKey: `${candidate.TargetEntityName}::${candidate.TargetKey ? candidate.TargetKey.ToConcatenatedString() : 'pending'}`,
302
+ Kind: candidate.Kind,
303
+ RelatedEntityName: candidate.TargetEntityName,
304
+ Policy: resolution.Policy,
305
+ PolicySource: resolution.PolicySource,
306
+ Locked: resolution.Locked,
307
+ JoinField: candidate.JoinField,
308
+ RelationshipID: candidate.Relationship?.ID,
309
+ });
310
+ if (resolution.Policy === 'Skip') {
311
+ excludedNodes.push({
312
+ EntityName: candidate.TargetEntityName,
313
+ SourceKey: candidate.TargetKey ? candidate.TargetKey.ToConcatenatedString() : '',
314
+ Reason: `Edge policy resolved to Skip from ${resolution.PolicySource}`,
315
+ });
316
+ }
317
+ return resolution.Policy;
318
+ },
319
+ }, contextUser);
320
+ // A root the walker couldn't load (a wrong ID, row-level security, no read permission) comes
321
+ // back empty. Planning it anyway would create a row from defaults and link it to a source
322
+ // that isn't there, so refuse. The message doesn't say which, so it can't reveal a hidden row.
323
+ if (!rootNode.RecordData || Object.keys(rootNode.RecordData).length === 0) {
324
+ warnings.push({
325
+ Code: 'SOURCE_NOT_FOUND',
326
+ Severity: 'Error',
327
+ Message: `The '${entityName}' record to clone wasn't found, or you can't read it.`,
328
+ });
329
+ // Nothing to map: planning on an empty row would only invent a Create node.
330
+ return refusedPlan();
331
+ }
332
+ // Flatten graph nodes, leaving out rows a relationship's ExcludeRows names (and everything under them)
333
+ const flatGraphNodes = this.dropExcludedRows(walker.FlattenTopological(rootNode), rootConfig, entityName, excludedNodes, warnings);
334
+ // Build concrete edges connecting discovered graph nodes
335
+ const edges = [];
336
+ for (const depNode of flatGraphNodes) {
337
+ if (depNode.DiscoveringEdge) {
338
+ const disc = depNode.DiscoveringEdge;
339
+ // Up and down a hierarchy share (from, entity, join field), so prefer the candidate
340
+ // for this exact target row when there is one.
341
+ const sameEdge = (c) => c.FromKey === disc.FromKey &&
342
+ c.RelatedEntityName === disc.TargetEntityName &&
343
+ c.JoinField.toLowerCase() === disc.JoinField.toLowerCase();
344
+ const cand = candidateEdges.find((c) => sameEdge(c) && c.ToKey === disc.ToKey) ?? candidateEdges.find(sameEdge);
345
+ edges.push({
346
+ FromKey: disc.FromKey,
347
+ ToKey: disc.ToKey,
348
+ Kind: disc.Kind,
349
+ RelatedEntityName: disc.TargetEntityName,
350
+ Policy: cand?.Policy ?? 'Deep',
351
+ PolicySource: cand?.PolicySource ?? 'Entity',
352
+ Locked: cand?.Locked ?? false,
353
+ JoinField: disc.JoinField,
354
+ RelationshipID: disc.Relationship?.ID ?? cand?.RelationshipID,
355
+ });
356
+ }
357
+ }
358
+ for (const cand of candidateEdges) {
359
+ if (!edges.some(e => e.FromKey === cand.FromKey && e.ToKey === cand.ToKey && e.JoinField.toLowerCase() === cand.JoinField.toLowerCase())) {
360
+ edges.push(cand);
361
+ }
362
+ }
363
+ // Identify which nodes are 'Reference' nodes vs 'Create' candidates
364
+ // Root node (depth 0) is always Create candidate.
365
+ // Child node is Reference if reached via a 'Reference' edge or if its parent is Reference.
366
+ const referenceNodeKeys = new Set();
367
+ for (const depNode of flatGraphNodes) {
368
+ const nodeKey = `${depNode.EntityName}::${depNode.RecordID}`;
369
+ if (depNode.Depth === 0) {
370
+ continue;
371
+ }
372
+ const disc = depNode.DiscoveringEdge;
373
+ const parentKey = disc ? disc.FromKey : null;
374
+ const isParentReference = parentKey ? (referenceNodeKeys.has(parentKey) || referenceNodeKeys.has(parentKey.split('::')[1] ?? '')) : false;
375
+ const matchingEdge = disc ? edges.find(e => e.FromKey === disc.FromKey &&
376
+ e.ToKey === disc.ToKey &&
377
+ e.JoinField.toLowerCase() === disc.JoinField.toLowerCase()) : null;
378
+ const edgePolicy = matchingEdge?.Policy ?? (disc?.Kind === 'ForwardFK' || disc?.Kind === 'SelfPointer' ? 'Reference' : 'Deep');
379
+ if (isParentReference || edgePolicy === 'Reference') {
380
+ referenceNodeKeys.add(nodeKey);
381
+ referenceNodeKeys.add(depNode.RecordID);
382
+ }
383
+ }
384
+ // Check MaxRecords cap (only counts created nodes per spec §5.5)
385
+ const createCandidateNodes = flatGraphNodes.filter(n => !referenceNodeKeys.has(`${n.EntityName}::${n.RecordID}`));
386
+ if (createCandidateNodes.length > maxRecords) {
387
+ warnings.push({
388
+ Code: 'CAP_EXCEEDED',
389
+ Severity: 'Error',
390
+ Message: `Clone plan creates ${createCandidateNodes.length} records, which exceeds the configured MaxRecords cap of ${maxRecords}.`,
391
+ });
392
+ planBlocked = true;
393
+ }
394
+ // Allocate target keys for Created nodes (see CloneKeys.ts). Minted UUID keys go first and
395
+ // into the key map, so derived keys (composite or natural) can remap their FK columns to them.
396
+ const keyMap = {};
397
+ const targetKeys = new Map();
398
+ const unchangedKeyNodes = new Set();
399
+ const createNodes = flatGraphNodes.filter((n) => !referenceNodeKeys.has(`${n.EntityName}::${n.RecordID}`));
400
+ for (const depNode of createNodes) {
401
+ if (KeyStrategyFor(depNode.EntityInfo) !== 'mint')
402
+ continue;
403
+ const nodeKey = `${depNode.EntityName}::${depNode.RecordID}`;
404
+ const targetUuid = GenerateUUID();
405
+ targetKeys.set(nodeKey, targetUuid);
406
+ // Single-column key: map the source value (and the record id) to the new UUID so FK columns remap.
407
+ const sourceValue = String(depNode.RecordKey?.KeyValuePairs?.[0]?.Value ?? '');
408
+ for (const k of [depNode.RecordID, sourceValue]) {
409
+ if (!k)
410
+ continue;
411
+ keyMap[k] = targetUuid;
412
+ keyMap[`${depNode.EntityName}::${k}`] = targetUuid;
413
+ }
414
+ }
415
+ // Rows whose key the database assigns: FK key columns pointing at them are filled at save.
416
+ const serverAssigned = new Set();
417
+ for (const depNode of createNodes) {
418
+ if (KeyStrategyFor(depNode.EntityInfo) !== 'server')
419
+ continue;
420
+ const sourceValue = String(depNode.RecordKey?.KeyValuePairs?.[0]?.Value ?? '');
421
+ if (sourceValue)
422
+ serverAssigned.add(`${depNode.EntityName}::${sourceValue}`);
423
+ }
424
+ for (const depNode of createNodes) {
425
+ const nodeKey = `${depNode.EntityName}::${depNode.RecordID}`;
426
+ const strategy = KeyStrategyFor(depNode.EntityInfo);
427
+ if (strategy === 'server') {
428
+ targetKeys.set(nodeKey, null); // the database assigns it on insert
429
+ }
430
+ else if (strategy === 'derived') {
431
+ const derived = DeriveTargetKey(depNode.EntityInfo, depNode.RecordKey, keyMap, serverAssigned);
432
+ const pks = depNode.EntityInfo.PrimaryKeys ?? [];
433
+ const onlyKey = pks.length === 1 ? depNode.EntityInfo.Fields.find((f) => f.Name === pks[0].Name) ?? pks[0] : null;
434
+ if (!derived.Changed && onlyKey && IsUuidColumn(onlyKey)) {
435
+ // A single UUID key that is also an FK (an IS-A subtype) whose parent is not in the
436
+ // clone: mint it; saving the subtype creates its parent row under the same ID.
437
+ const targetUuid = GenerateUUID();
438
+ targetKeys.set(nodeKey, targetUuid);
439
+ keyMap[depNode.RecordID] = targetUuid;
440
+ keyMap[`${depNode.EntityName}::${String(depNode.RecordKey.KeyValuePairs[0]?.Value ?? '')}`] = targetUuid;
441
+ continue;
442
+ }
443
+ targetKeys.set(nodeKey, ToRecordKeyString(derived.Key));
444
+ if (!derived.Changed)
445
+ unchangedKeyNodes.add(nodeKey);
446
+ }
447
+ }
448
+ // Retarget: point a root foreign key at another record. Only the fields the entity offers
449
+ // for it (Clone.UI.RetargetFields) apply; they then go through the field-override rules.
450
+ const retargetable = new Set((rootConfig?.UI?.RetargetFields ?? []).map((f) => f.toLowerCase()));
451
+ const retargetOverrides = {};
452
+ for (const r of request.Options?.Retarget ?? []) {
453
+ if (r.EntityName === entityName && retargetable.has(r.Field.toLowerCase())) {
454
+ retargetOverrides[r.Field] = r.Value;
455
+ }
456
+ else {
457
+ warnings.push({
458
+ Code: 'OPTION_OVERRIDE_IGNORED',
459
+ Severity: 'Warning',
460
+ Field: r.Field,
461
+ Message: `Retarget of '${r.EntityName}.${r.Field}' was ignored: the entity's clone configuration doesn't offer that field for retargeting.`,
462
+ });
463
+ }
464
+ }
465
+ // Naming for each row, and the names already taken, so a repeat clone doesn't collide at save.
466
+ const cloneConfigOf = (e) => e.CloneConfig ?? null;
467
+ // A request may pick the naming only where the configuration lets it edit fields, like any
468
+ // other request value: with UserEditable 'none' a caller can't ask for 'none' and keep the name.
469
+ const requestNaming = ['fields', 'all'].includes(rootConfig?.UserEditable ?? 'all');
470
+ const namingFor = (depNode) => {
471
+ if (depNode.Depth === 0) {
472
+ return {
473
+ Template: (requestNaming ? request.Options?.Naming?.Template || request.Options?.NamingTemplate : undefined) || rootConfig?.Naming?.Template,
474
+ Strategy: (requestNaming ? request.Options?.Naming?.Strategy || request.Options?.NamingStrategy : undefined) || rootConfig?.Naming?.Strategy || 'suffix',
475
+ Context: { UserName: contextUser.Name },
476
+ };
477
+ }
478
+ const template = BagEntry(rootConfig?.Descendants, depNode.EntityName)?.Naming?.Template || cloneConfigOf(depNode.EntityInfo)?.Naming?.Template;
479
+ return {
480
+ Template: template,
481
+ Strategy: BagEntry(rootConfig?.Descendants, depNode.EntityName)?.Naming?.Strategy || cloneConfigOf(depNode.EntityInfo)?.Naming?.Strategy || (template ? 'suffix' : 'none'),
482
+ Context: { UserName: contextUser.Name },
483
+ };
484
+ };
485
+ const existingNames = await this.loadExistingNames(createNodes, namingFor, contextUser);
486
+ // Map nodes
487
+ const nodes = [];
488
+ const plannedNodesForCollisionCheck = [];
489
+ for (const depNode of flatGraphNodes) {
490
+ const nodeKey = `${depNode.EntityName}::${depNode.RecordID}`;
491
+ const isReference = referenceNodeKeys.has(nodeKey);
492
+ const entInfo = depNode.EntityInfo;
493
+ const isRoot = depNode.Depth === 0;
494
+ const disc = depNode.DiscoveringEdge;
495
+ const viaEdge = disc ? edges.find(e => e.FromKey === disc.FromKey &&
496
+ e.ToKey === disc.ToKey &&
497
+ e.JoinField.toLowerCase() === disc.JoinField.toLowerCase()) ?? null : null;
498
+ if (isReference) {
499
+ const planNode = {
500
+ Key: nodeKey,
501
+ NodeKey: nodeKey,
502
+ EntityName: depNode.EntityName,
503
+ SourceKey: depNode.RecordID,
504
+ TargetKey: null,
505
+ Action: 'Reference',
506
+ Reason: 'Referenced existing record',
507
+ Depth: depNode.Depth,
508
+ ParentKey: disc ? disc.FromKey : null,
509
+ Via: viaEdge,
510
+ DisplayName: (depNode.RecordData?.[entInfo.NameField?.Name || 'Name'] ? String(depNode.RecordData[entInfo.NameField?.Name || 'Name']) : null) || depNode.EntityName,
511
+ FieldChanges: [],
512
+ Warnings: [],
513
+ Route: 'Sidecar',
514
+ };
515
+ nodes.push(planNode);
516
+ continue;
517
+ }
518
+ const perms = entInfo.GetUserPermisions(contextUser);
519
+ let nodeBlocked = false;
520
+ if (!perms.CanCreate) {
521
+ warnings.push({
522
+ Code: 'NO_CREATE_PERMISSION',
523
+ Severity: 'Error',
524
+ NodeKey: nodeKey,
525
+ Message: `User lacks CanCreate permission on entity '${depNode.EntityName}'.`,
526
+ });
527
+ nodeBlocked = true;
528
+ planBlocked = true;
529
+ }
530
+ // Every created row is checked against its own entity's clone authorization (plan §9.1).
531
+ // The root's own check ran above; repeating it would only duplicate the warning.
532
+ const nodeAuth = isRoot ? rootAuth : authorizer.CanCloneEntity(entInfo, contextUser);
533
+ if (!isRoot && !nodeAuth.Granted) {
534
+ warnings.push({
535
+ Code: 'FORBIDDEN',
536
+ Severity: 'Error',
537
+ NodeKey: nodeKey,
538
+ Message: `Copying '${depNode.EntityName}' rows requires the '${nodeAuth.Name}' authorization. Set that relationship to Reference or Skip, or ask for the authorization.`,
539
+ });
540
+ nodeBlocked = true;
541
+ planBlocked = true;
542
+ }
543
+ const targetKey = targetKeys.get(nodeKey) ?? null;
544
+ // A derived key with no remapped column equals the source key; saving would collide.
545
+ if (unchangedKeyNodes.has(nodeKey)) {
546
+ warnings.push({
547
+ Code: 'TARGET_KEY_UNCHANGED',
548
+ Severity: 'Error',
549
+ NodeKey: nodeKey,
550
+ Message: `A copy of this '${depNode.EntityName}' row would keep the same primary key (${depNode.EntityInfo.PrimaryKeys.map((k) => k.Name).join(', ')}), because none of its key columns point at a record being cloned. Skip that relationship, or clone it from a parent whose key it contains.`,
551
+ });
552
+ nodeBlocked = true;
553
+ planBlocked = true;
554
+ }
555
+ const fieldMappingResult = MapFieldsForClone({
556
+ EntityName: depNode.EntityName,
557
+ Fields: FieldMetaFromEntity(entInfo),
558
+ SourceRecord: depNode.RecordData,
559
+ CurrentUserId: contextUser.ID,
560
+ KeyMap: keyMap,
561
+ NamingOptions: {
562
+ ...namingFor(depNode),
563
+ UserName: contextUser.Name,
564
+ ExistingNamesByField: existingNames.get(depNode.EntityName),
565
+ },
566
+ FieldRules: (() => {
567
+ const entConfig = entInfo.CloneConfig ?? null;
568
+ const descConfig = !isRoot ? BagEntry(rootConfig?.Descendants, depNode.EntityName) : undefined;
569
+ const entOwnership = entConfig?.Fields?.Ownership || entConfig?.Ownership;
570
+ const descOwnership = descConfig?.Fields?.Ownership || descConfig?.Ownership;
571
+ const combinedOwnership = [
572
+ ...(entOwnership || []),
573
+ ...(descOwnership || []),
574
+ ];
575
+ const combinedExclude = [
576
+ ...(entConfig?.Fields?.Exclude || []),
577
+ ...(descConfig?.Fields?.Exclude || []),
578
+ ];
579
+ const combinedReset = {
580
+ ...(entConfig?.Fields?.Reset || {}),
581
+ ...(descConfig?.Fields?.Reset || {}),
582
+ };
583
+ const combinedServerAllocated = [
584
+ ...(entConfig?.Fields?.ServerAllocated || []),
585
+ ...(descConfig?.Fields?.ServerAllocated || []),
586
+ ];
587
+ const combinedPromptFor = [
588
+ ...(entConfig?.Fields?.PromptFor || []),
589
+ ...(descConfig?.Fields?.PromptFor || []),
590
+ ];
591
+ const hasRules = combinedOwnership.length > 0 ||
592
+ combinedExclude.length > 0 ||
593
+ Object.keys(combinedReset).length > 0 ||
594
+ combinedServerAllocated.length > 0 ||
595
+ combinedPromptFor.length > 0 ||
596
+ entConfig?.Fields?.Rules ||
597
+ descConfig?.Fields?.Rules ||
598
+ entConfig?.Fields?.JsonRemap;
599
+ if (!hasRules)
600
+ return undefined;
601
+ return {
602
+ Exclude: combinedExclude.length > 0 ? combinedExclude : undefined,
603
+ Reset: Object.keys(combinedReset).length > 0 ? combinedReset : undefined,
604
+ Ownership: combinedOwnership.length > 0 ? combinedOwnership : undefined,
605
+ ServerAllocated: combinedServerAllocated.length > 0 ? combinedServerAllocated : undefined,
606
+ PromptFor: combinedPromptFor.length > 0 ? combinedPromptFor : undefined,
607
+ Rules: (descConfig?.Fields?.Rules ?? entConfig?.Fields?.Rules),
608
+ JsonRemap: entConfig?.Fields?.JsonRemap,
609
+ };
610
+ })(),
611
+ RequestOverrides: isRoot ? { ...(request.FieldOverrides ?? request.Options?.FieldOverrides ?? {}), ...retargetOverrides } : undefined,
612
+ PromptedValues: isRoot ? (request.PromptedValues ?? request.Options?.PromptedValues) : undefined,
613
+ RequestFieldsEditable: ['fields', 'all'].includes(rootConfig?.UserEditable ?? 'all'),
614
+ FLS: fieldLevelDenials(entInfo, contextUser),
615
+ IsRoot: isRoot,
616
+ HierarchyParentField: entInfo.Fields.find((f) => f.IsHierarchy)?.Name,
617
+ // Undefined keeps the root under its current parent; a value (or null) moves it.
618
+ NewParentKey: isRoot ? request.Options?.NewParentKey : undefined,
619
+ });
620
+ // IS-A subtype and embedded rows need their own save recipe (AttachToParent, the owner's
621
+ // FK), which this release doesn't implement: routing them through a collection would
622
+ // insert a second parent row or write a column the peer doesn't have. Refuse instead.
623
+ if (disc && (disc.Kind === 'IsASubtype' || disc.Kind === 'Embedded') && viaEdge?.Policy === 'Deep') {
624
+ warnings.push({
625
+ Code: 'EDGE_KIND_UNSUPPORTED',
626
+ Severity: 'Error',
627
+ NodeKey: nodeKey,
628
+ Message: `Copying ${disc.Kind === 'IsASubtype' ? 'IS-A subtype' : 'embedded'} rows ('${depNode.EntityName}') isn't supported yet. Set Subtypes to exclude, or that relationship to Reference or Skip.`,
629
+ });
630
+ planBlocked = true;
631
+ nodeBlocked = true;
632
+ }
633
+ // Prompted fields are the ones the user must supply (Email on a user clone): without a
634
+ // value the copy would carry the source's, so the plan can't run until they're entered.
635
+ if (isRoot) {
636
+ const prompted = request.PromptedValues ?? request.Options?.PromptedValues ?? {};
637
+ for (const field of rootConfig?.Fields?.PromptFor ?? []) {
638
+ const value = prompted[field];
639
+ if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {
640
+ warnings.push({
641
+ Code: 'UNIQUE_PROMPT_REQUIRED',
642
+ Severity: 'Error',
643
+ NodeKey: nodeKey,
644
+ Field: field,
645
+ Message: `Enter a value for '${field}': the copy can't reuse the source's.`,
646
+ });
647
+ planBlocked = true;
648
+ }
649
+ }
650
+ }
651
+ for (const field of fieldMappingResult.NamingFailures) {
652
+ warnings.push({
653
+ Code: 'NAME_UNAVAILABLE',
654
+ Severity: 'Error',
655
+ NodeKey: nodeKey,
656
+ Field: field,
657
+ Message: `No free value for '${field}' fits the column: every candidate is taken or too long. Enter a name, or clear an existing copy.`,
658
+ });
659
+ planBlocked = true;
660
+ }
661
+ for (const ignored of fieldMappingResult.IgnoredRequestValues) {
662
+ warnings.push({
663
+ Code: 'OPTION_OVERRIDE_IGNORED',
664
+ Severity: 'Warning',
665
+ NodeKey: nodeKey,
666
+ Field: ignored.Field,
667
+ Message: `${ignored.Kind === 'Prompt' ? 'Prompted value' : 'Override'} for '${ignored.Field}' was ignored: ${ignored.Reason}.`,
668
+ });
669
+ }
670
+ const planNode = {
671
+ Key: nodeKey,
672
+ NodeKey: nodeKey,
673
+ EntityName: depNode.EntityName,
674
+ SourceKey: depNode.RecordID,
675
+ TargetKey: targetKey,
676
+ Action: nodeBlocked ? 'Blocked' : 'Create',
677
+ Reason: nodeBlocked ? 'Permission error' : 'Clone record',
678
+ Depth: depNode.Depth,
679
+ ParentKey: depNode.DiscoveringEdge ? depNode.DiscoveringEdge.FromKey : null,
680
+ Via: viaEdge,
681
+ DisplayName: (depNode.RecordData?.[entInfo.NameField?.Name || 'Name'] ? String(depNode.RecordData[entInfo.NameField?.Name || 'Name']) : null) || depNode.EntityName,
682
+ FieldChanges: fieldMappingResult.FieldChanges,
683
+ Warnings: [],
684
+ Route: isRoot ? 'RootSave' : 'Collection',
685
+ };
686
+ nodes.push(planNode);
687
+ plannedNodesForCollisionCheck.push({
688
+ NodeKey: planNode.NodeKey ?? planNode.Key,
689
+ EntityName: planNode.EntityName,
690
+ ParentKey: planNode.ParentKey,
691
+ Values: fieldMappingResult.MappedValues,
692
+ });
693
+ }
694
+ // Intra-plan collision detection across siblings
695
+ const uniqueKeysByEntity = {};
696
+ for (const depNode of flatGraphNodes) {
697
+ const nodeKey = `${depNode.EntityName}::${depNode.RecordID}`;
698
+ if (referenceNodeKeys.has(nodeKey)) {
699
+ continue;
700
+ }
701
+ if (!uniqueKeysByEntity[depNode.EntityName]) {
702
+ const uqs = [];
703
+ if (depNode.EntityInfo.CloneConfig?.Fields?.UniqueKeys && depNode.EntityInfo.CloneConfig.Fields.UniqueKeys.length > 0) {
704
+ uqs.push(...depNode.EntityInfo.CloneConfig.Fields.UniqueKeys);
705
+ }
706
+ else {
707
+ // Metadata flags every column of a composite unique constraint IsUnique (Action
708
+ // Params: Name and ActionID, unique together), so the flagged columns form one key.
709
+ // A copy whose FK column is remapped then never collides with its source.
710
+ const flagged = depNode.EntityInfo.Fields.filter((f) => f.IsUnique && !f.IsPrimaryKey).map((f) => f.Name);
711
+ if (flagged.length > 0)
712
+ uqs.push({ Fields: flagged, Scope: 'Global' });
713
+ }
714
+ uniqueKeysByEntity[depNode.EntityName] = uqs;
715
+ }
716
+ }
717
+ const intraPlanCollisions = DetectIntraPlanCollisions(plannedNodesForCollisionCheck, uniqueKeysByEntity);
718
+ if (intraPlanCollisions.length > 0) {
719
+ for (const col of intraPlanCollisions) {
720
+ warnings.push({
721
+ Code: 'INTRA_PLAN_COLLISION',
722
+ Severity: 'Error',
723
+ NodeKey: col.NodeKeyB,
724
+ Message: `Intra-plan collision detected between '${col.NodeKeyA}' and '${col.NodeKeyB}' on unique fields: [${col.Fields.join(', ')}].`,
725
+ });
726
+ planBlocked = true;
727
+ }
728
+ }
729
+ // Calculate Plan Hash (ignoring TargetKey values)
730
+ const planHash = ComputeClonePlanHash({
731
+ Nodes: nodes,
732
+ Edges: edges,
733
+ Excluded: excludedNodes,
734
+ });
735
+ const byEntity = {};
736
+ for (const n of nodes) {
737
+ if (!byEntity[n.EntityName]) {
738
+ byEntity[n.EntityName] = { Create: 0, Reference: 0, Skip: 0 };
739
+ }
740
+ if (n.Action === 'Create')
741
+ byEntity[n.EntityName].Create++;
742
+ else if (n.Action === 'Reference')
743
+ byEntity[n.EntityName].Reference++;
744
+ else if (n.Action === 'Skip')
745
+ byEntity[n.EntityName].Skip++;
746
+ }
747
+ const createCount = nodes.filter((n) => n.Action === 'Create').length;
748
+ // The root's own planned key; empty when the database assigns it on insert.
749
+ const rootTargetKey = targetKeys.get(`${entityName}::${key.ToConcatenatedString()}`) ?? '';
750
+ return {
751
+ PlanVersion: 1,
752
+ Hash: planHash,
753
+ PlanHash: planHash,
754
+ Roots: [`${entityName}::${key.ToConcatenatedString()}`],
755
+ RootEntityName: entityName,
756
+ RootSourceKey: key.ToConcatenatedString(),
757
+ RootTargetKey: rootTargetKey,
758
+ Nodes: nodes,
759
+ Edges: edges,
760
+ Excluded: excludedNodes,
761
+ Counts: {
762
+ ByEntity: byEntity,
763
+ Create: createCount,
764
+ Total: nodes.length,
765
+ },
766
+ Warnings: warnings,
767
+ Blocked: planBlocked,
768
+ EffectiveOptions: effectiveOptions,
769
+ Overrides: resolved.Overrides,
770
+ Reason: request.Options?.Reason,
771
+ };
772
+ }
773
+ /**
774
+ * Values already taken in each field a clone renames, per entity, found with one prefix query
775
+ * per (entity, field). Without them the rename can't avoid "Sales (copy)" on the second clone.
776
+ */
777
+ async loadExistingNames(createNodes, namingFor, contextUser) {
778
+ const prefixes = new Map();
779
+ for (const node of createNodes) {
780
+ const naming = namingFor(node);
781
+ for (const field of FieldMetaFromEntity(node.EntityInfo).filter(IsRenameField)) {
782
+ const value = node.RecordData?.[field.Name];
783
+ if (value === null || value === undefined || value === '')
784
+ continue;
785
+ // LIKE wildcards and SQL Server's [ ] classes can't be escaped portably (PostgreSQL's
786
+ // provider rewrites [word] even inside literals), so match on the text before the first
787
+ // one. That only widens the lookup; names are compared exactly afterwards.
788
+ const full = NameCollisionPrefix(String(value), { ...naming, MaxLength: field.MaxLength });
789
+ if (!full)
790
+ continue; // this strategy doesn't rename
791
+ const prefix = full.split(/[[\]%_\\]/)[0]; // may be '' — then every value is read
792
+ const k = `${node.EntityName}\u0000${field.Name}`;
793
+ if (!prefixes.has(k))
794
+ prefixes.set(k, { entity: node.EntityName, field: field.Name, prefixes: new Set() });
795
+ prefixes.get(k).prefixes.add(prefix);
796
+ }
797
+ }
798
+ const result = new Map();
799
+ const rv = RunView.FromMetadataProvider(this.Provider);
800
+ for (const { entity, field, prefixes: set } of prefixes.values()) {
801
+ const all = [...set];
802
+ const taken = new Set();
803
+ for (let i = 0; i < all.length; i += 50) {
804
+ const filter = all.slice(i, i + 50).map((p) => (p ? `${field} LIKE '${EscapeSQLString(p)}%'` : `${field} IS NOT NULL`)).join(' OR ');
805
+ const res = await rv.RunView({ EntityName: entity, Fields: [field], ExtraFilter: `(${filter})`, ResultType: 'simple', MaxRows: 5000 }, contextUser);
806
+ for (const row of res?.Success ? res.Results ?? [] : []) {
807
+ if (row[field] !== null && row[field] !== undefined)
808
+ taken.add(String(row[field]));
809
+ }
810
+ }
811
+ if (!result.has(entity))
812
+ result.set(entity, {});
813
+ result.get(entity)[field] = taken;
814
+ }
815
+ return result;
816
+ }
817
+ /**
818
+ * Drops rows matching their relationship's `ExcludeRows`, with every row discovered beneath them.
819
+ * The rule is looked up in the same order as the edge's policy (`ResolveEdgePolicy`): the
820
+ * relationship's own bag, then the root's entry by relationship ID, `Child.JoinField`, then
821
+ * `Child`. One Info warning per relationship.
822
+ */
823
+ dropExcludedRows(nodes, rootConfig, rootEntityName, excludedNodes, warnings) {
824
+ const dropped = new Set();
825
+ const counts = new Map();
826
+ // Keyed by entity and record, as the walker's edge keys are: a bare record ID can repeat across entities.
827
+ const isDropped = (key) => dropped.has(key);
828
+ const kept = [];
829
+ for (const node of nodes) {
830
+ const edge = node.DiscoveringEdge;
831
+ if (!edge) {
832
+ kept.push(node);
833
+ continue;
834
+ }
835
+ // Same chain as ResolveEdgePolicy: the relationship's own bag, the root's entry (by
836
+ // relationship ID, Child.JoinField, Child), then, below the root, the parent entity's own
837
+ // entry. Descendants entries carry field rules, not row exclusions. A bare child key in the
838
+ // root's bag applies only to edges from the root's entity.
839
+ const entryIn = (bag, bareKey) => (edge.Relationship?.ID ? BagEntry(bag, edge.Relationship.ID)?.ExcludeRows : undefined) ??
840
+ BagEntry(bag, `${edge.TargetEntityName}.${edge.JoinField}`)?.ExcludeRows ??
841
+ (bareKey ? BagEntry(bag, edge.TargetEntityName)?.ExcludeRows : undefined);
842
+ const parentBag = edge.SourceEntityName !== rootEntityName ? this.Provider.EntityByName(edge.SourceEntityName)?.CloneConfig?.Relationships : undefined;
843
+ const rules = edge.Relationship?.CloneConfig?.ExcludeRows ??
844
+ entryIn(rootConfig?.Relationships, IsEdgeFromRoot(edge.SourceEntityName, rootEntityName)) ??
845
+ entryIn(parentBag, true);
846
+ const matched = RowMatchesExclusion(node.RecordData ?? {}, rules);
847
+ if (!matched && !isDropped(edge.FromKey)) {
848
+ kept.push(node);
849
+ continue;
850
+ }
851
+ dropped.add(`${node.EntityName}::${node.RecordID}`);
852
+ excludedNodes.push({
853
+ EntityName: node.EntityName,
854
+ SourceKey: node.RecordID,
855
+ Reason: matched ? 'Row matches the relationship\'s ExcludeRows.' : 'Parent row was excluded.',
856
+ });
857
+ if (matched)
858
+ counts.set(node.EntityName, (counts.get(node.EntityName) ?? 0) + 1);
859
+ }
860
+ for (const [entityName, count] of counts) {
861
+ warnings.push({
862
+ Code: 'ROWS_EXCLUDED',
863
+ Severity: 'Info',
864
+ Message: `${count} ${entityName} row${count === 1 ? '' : 's'} left out by the configuration (per-person or per-device data).`,
865
+ });
866
+ }
867
+ return kept;
868
+ }
869
+ }
870
+ //# sourceMappingURL=ClonePlanner.js.map