@memberjunction/core 5.51.0 → 6.1.0-edge.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.
Files changed (73) hide show
  1. package/LICENSE +7 -0
  2. package/dist/generic/baseEngine.d.ts.map +1 -1
  3. package/dist/generic/baseEngine.js +13 -2
  4. package/dist/generic/baseEngine.js.map +1 -1
  5. package/dist/generic/baseEngineRegistry.d.ts +14 -0
  6. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  7. package/dist/generic/baseEngineRegistry.js +32 -0
  8. package/dist/generic/baseEngineRegistry.js.map +1 -1
  9. package/dist/generic/baseEntity.d.ts +329 -26
  10. package/dist/generic/baseEntity.d.ts.map +1 -1
  11. package/dist/generic/baseEntity.js +788 -79
  12. package/dist/generic/baseEntity.js.map +1 -1
  13. package/dist/generic/databaseProviderBase.d.ts +54 -17
  14. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  15. package/dist/generic/databaseProviderBase.js +133 -52
  16. package/dist/generic/databaseProviderBase.js.map +1 -1
  17. package/dist/generic/entityCompanion.d.ts +218 -0
  18. package/dist/generic/entityCompanion.d.ts.map +1 -0
  19. package/dist/generic/entityCompanion.js +170 -0
  20. package/dist/generic/entityCompanion.js.map +1 -0
  21. package/dist/generic/entityInfo.d.ts +146 -0
  22. package/dist/generic/entityInfo.d.ts.map +1 -1
  23. package/dist/generic/entityInfo.js +188 -0
  24. package/dist/generic/entityInfo.js.map +1 -1
  25. package/dist/generic/entitySavePlan.d.ts +199 -0
  26. package/dist/generic/entitySavePlan.d.ts.map +1 -0
  27. package/dist/generic/entitySavePlan.js +213 -0
  28. package/dist/generic/entitySavePlan.js.map +1 -0
  29. package/dist/generic/entityTransactionScope.d.ts +125 -0
  30. package/dist/generic/entityTransactionScope.d.ts.map +1 -0
  31. package/dist/generic/entityTransactionScope.js +115 -0
  32. package/dist/generic/entityTransactionScope.js.map +1 -0
  33. package/dist/generic/interfaces.d.ts +93 -35
  34. package/dist/generic/interfaces.d.ts.map +1 -1
  35. package/dist/generic/interfaces.js +27 -0
  36. package/dist/generic/interfaces.js.map +1 -1
  37. package/dist/generic/providerBase.d.ts +13 -0
  38. package/dist/generic/providerBase.d.ts.map +1 -1
  39. package/dist/generic/providerBase.js +64 -5
  40. package/dist/generic/providerBase.js.map +1 -1
  41. package/dist/generic/relatedRecordBatchLoader.d.ts +39 -0
  42. package/dist/generic/relatedRecordBatchLoader.d.ts.map +1 -0
  43. package/dist/generic/relatedRecordBatchLoader.js +154 -0
  44. package/dist/generic/relatedRecordBatchLoader.js.map +1 -0
  45. package/dist/generic/relatedRecordCollection.d.ts +578 -0
  46. package/dist/generic/relatedRecordCollection.d.ts.map +1 -0
  47. package/dist/generic/relatedRecordCollection.js +1004 -0
  48. package/dist/generic/relatedRecordCollection.js.map +1 -0
  49. package/dist/generic/saveEntityGraphOperation.d.ts +148 -0
  50. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -0
  51. package/dist/generic/saveEntityGraphOperation.js +157 -0
  52. package/dist/generic/saveEntityGraphOperation.js.map +1 -0
  53. package/dist/generic/securityInfo.d.ts +99 -1
  54. package/dist/generic/securityInfo.d.ts.map +1 -1
  55. package/dist/generic/securityInfo.js +88 -6
  56. package/dist/generic/securityInfo.js.map +1 -1
  57. package/dist/generic/telemetryManager.d.ts +21 -1
  58. package/dist/generic/telemetryManager.d.ts.map +1 -1
  59. package/dist/generic/telemetryManager.js +21 -6
  60. package/dist/generic/telemetryManager.js.map +1 -1
  61. package/dist/index.d.ts +6 -1
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +6 -3
  64. package/dist/index.js.map +1 -1
  65. package/dist/views/runView.d.ts +31 -0
  66. package/dist/views/runView.d.ts.map +1 -1
  67. package/dist/views/runView.js.map +1 -1
  68. package/package.json +13 -13
  69. package/readme.md +159 -1
  70. package/dist/generic/runReport.d.ts +0 -25
  71. package/dist/generic/runReport.d.ts.map +0 -1
  72. package/dist/generic/runReport.js +0 -38
  73. package/dist/generic/runReport.js.map +0 -1
@@ -0,0 +1,1004 @@
1
+ /**
2
+ * @fileoverview `RelatedRecordCollection<T>` — a typed, transportable collection of child records that
3
+ * loads, validates and persists as one unit with its parent.
4
+ *
5
+ * ## The problem it replaces
6
+ *
7
+ * Three MemberJunction applications independently hand-rolled this same pattern, and each got a
8
+ * different subset of it right:
9
+ *
10
+ * | | Order | PaymentHeader | JournalEntry |
11
+ * |---|---|---|---|
12
+ * | Typed | ✓ | ✗ (`BaseEntity[]`) | ✓ |
13
+ * | Loads children | ✗ | ✗ | ✓ |
14
+ * | Tracks removals | ✗ | ✗ | ✓ |
15
+ * | Add/remove API | raw setter | raw setter | ✓ |
16
+ * | Re-sequences | ✗ | ✗ | ✓ |
17
+ *
18
+ * All three were server-only classes, because each cast the provider to `DatabaseProviderBase` to
19
+ * reach `BeginTransaction()`. `RelatedRecordCollection` is tier-neutral: it never touches a provider
20
+ * transaction itself, it only contributes nodes to an {@link EntitySavePlan}, and `BaseEntity`
21
+ * decides where that plan runs.
22
+ *
23
+ * @module @memberjunction/core
24
+ */
25
+ import { UUIDsEqual } from '@memberjunction/global';
26
+ import { BaseEngineRegistry } from './baseEngineRegistry.js';
27
+ import { IsVerboseLoggingEnabled, LogStatus } from './logging.js';
28
+ import { CompositeKey, KeyValuePair } from './compositeKey.js';
29
+ import { EntityCompanion } from './entityCompanion.js';
30
+ import { ValidationErrorInfo, ValidationErrorType } from './entityInfo.js';
31
+ import { LogError } from './logging.js';
32
+ /**
33
+ * A typed collection of child records that travels, validates and persists with its parent.
34
+ *
35
+ * Obtain one via `BaseEntity.DeclareRelatedRecords()` in a subclass constructor or field initialiser —
36
+ * do not construct it directly, or it will not be registered as a companion and will be silently
37
+ * ignored by load, validation and save.
38
+ *
39
+ * @typeParam T - The child entity type.
40
+ *
41
+ * @example Declaring a collection on a shared (client + server) entity subclass
42
+ * ```typescript
43
+ * @RegisterClass(BaseEntity, 'MJ_BizApps_Accounting: Journal Entries')
44
+ * export class JournalEntryEntity extends mjBizAppsAccountingJournalEntryEntity {
45
+ * public readonly Lines = this.DeclareRelatedRecords<JournalEntryLineEntity>({
46
+ * Name: 'Lines',
47
+ * RelatedEntity: 'MJ_BizApps_Accounting: Journal Entry Lines',
48
+ * RelatedEntityJoinField: 'JournalEntryID',
49
+ * OrderBy: 'LineNumber ASC',
50
+ * Load: 'explicit',
51
+ * OnRemove: 'delete',
52
+ * Sequence: { Field: 'LineNumber', From: 1 },
53
+ * });
54
+ *
55
+ * public override Validate(): ValidationResult {
56
+ * const result = super.Validate(); // fans out to companions
57
+ * assertBalanced(this.Lines.Items, result); // runs on BOTH tiers
58
+ * return result;
59
+ * }
60
+ * }
61
+ * ```
62
+ */
63
+ export class RelatedRecordCollection extends EntityCompanion {
64
+ /**
65
+ * @param owner - The parent entity.
66
+ * @param options - The collection declaration.
67
+ */
68
+ constructor(owner, options) {
69
+ super(owner);
70
+ this.items = [];
71
+ this.removed = [];
72
+ this.loaded = false;
73
+ /**
74
+ * The engine and property this collection last read from, plus enough about that array to tell
75
+ * cheaply whether it has moved on.
76
+ *
77
+ * A cache-sourced read-only collection is a **live view**, not a snapshot. `.claude/rules/data-access.md`
78
+ * spells out why: an engine responds to entity events either by mutating its array in place or —
79
+ * for ordered configs — by REASSIGNING the property wholesale, so a captured reference silently
80
+ * goes stale. The documented remedy is to resolve per-access from the engine plus the config's
81
+ * property name, which is what this records.
82
+ *
83
+ * Revalidation is deliberately cheap: identity catches a reassignment, length catches an
84
+ * in-place push or splice, and field-level edits need no detection at all because a read-only
85
+ * collection hands out the engine's own instances — the caller is already looking at them.
86
+ */
87
+ this.cacheDonor = null;
88
+ this.options = options;
89
+ this.assertDeclarationInvariants();
90
+ }
91
+ /**
92
+ * Rejects declarations whose combination of options cannot work, at declaration time.
93
+ *
94
+ * CodeGen already refuses these combinations for metadata-declared collections; enforcing them
95
+ * here as well means a hand-written declaration fails immediately with an accurate message,
96
+ * instead of at first read with a misleading one (`populateFromCache` bails on `!IsReadOnly`
97
+ * before ever consulting the donor, so a writable lazy collection used to throw "engine is not
98
+ * loaded yet" even when the engine was fully loaded).
99
+ */
100
+ assertDeclarationInvariants() {
101
+ if ((this.options.Load ?? 'explicit') !== 'lazy') {
102
+ return;
103
+ }
104
+ if ((this.options.Source ?? 'database') !== 'cache') {
105
+ throw new Error(`RelatedRecordCollection '${this.options.Name}': Load: 'lazy' requires Source: 'cache'. A lazy ` +
106
+ `fill happens inside a synchronous property getter, which cannot await a database load. ` +
107
+ `Declare Source: 'cache', or use Load: 'explicit' with an awaited Load().`);
108
+ }
109
+ if (this.options.ReadOnly === false) {
110
+ throw new Error(`RelatedRecordCollection '${this.options.Name}': Load: 'lazy' requires a read-only collection. ` +
111
+ `A writable cache collection copies records via the async GetEntityObject, which a synchronous ` +
112
+ `getter cannot await. Omit ReadOnly (cache-sourced collections default to read-only), or use ` +
113
+ `Load: 'explicit'.`);
114
+ }
115
+ }
116
+ /** @inheritdoc */
117
+ get Name() {
118
+ return this.options.Name;
119
+ }
120
+ /** The child entity's name in MJ metadata. */
121
+ get RelatedEntityName() {
122
+ return this.options.RelatedEntity;
123
+ }
124
+ /** The child field holding the foreign key back to the parent. */
125
+ get RelatedEntityJoinField() {
126
+ return this.options.RelatedEntityJoinField;
127
+ }
128
+ /** The `OrderBy` clause applied when loading, if declared. */
129
+ get OrderByClause() {
130
+ return this.options.OrderBy;
131
+ }
132
+ /** When this collection populates itself. */
133
+ get LoadMode() {
134
+ return this.options.Load ?? 'explicit';
135
+ }
136
+ /** What removal means for this collection. */
137
+ get RemovalMode() {
138
+ return this.options.OnRemove ?? 'delete';
139
+ }
140
+ /** Where this collection's records come from. Defaults to `'database'`. */
141
+ get Source() {
142
+ return this.options.Source ?? 'database';
143
+ }
144
+ /**
145
+ * Whether this collection refuses mutation.
146
+ *
147
+ * Defaults to `false`, **except for a cache-sourced collection**, which defaults to `true`
148
+ * because its records are the engine's own shared instances. An explicit `ReadOnly: false`
149
+ * still wins — and switches the cache path to copying, so the engine's objects stay untouched.
150
+ */
151
+ get IsReadOnly() {
152
+ return this.options.ReadOnly ?? this.Source === 'cache';
153
+ }
154
+ /**
155
+ * The retained children, in order.
156
+ *
157
+ * Read-only by design — mutate through {@link Add}, {@link Create} and {@link Remove} so that
158
+ * removals are tracked, sequence numbers stay correct, and the parent's `Dirty` flag reflects
159
+ * reality. Handing out a mutable array would make all three impossible to guarantee.
160
+ */
161
+ get Items() {
162
+ // `'lazy'` populates on first read. This is a side-effecting getter, deliberately: it is
163
+ // exactly what the hand-written memoized getters it replaces did, and it is only reachable
164
+ // for cache-sourced collections, where filling is synchronous. A database-sourced lazy
165
+ // collection cannot exist — CodeGen refuses the combination — because a getter cannot await.
166
+ if (!this.loaded && this.LoadMode === 'lazy') {
167
+ this.populateLazyOrThrow();
168
+ }
169
+ this.refreshCacheViewIfStale();
170
+ return this.items;
171
+ }
172
+ /**
173
+ * Iterates the retained records, so the collection works directly with `for…of`, spread and
174
+ * array destructuring:
175
+ *
176
+ * ```typescript
177
+ * for (const param of action.Params) { … }
178
+ * const all = [...action.Params];
179
+ * const [first, ...rest] = action.Params;
180
+ * ```
181
+ *
182
+ * This is the standard ES2015 iterable protocol — the same one `Map`, `Set` and `NodeList`
183
+ * implement — deliberately chosen over extending `Array`. Subclassing `Array` would inherit
184
+ * `push`, `splice`, `sort` and index assignment, every one of which bypasses the removal
185
+ * tracking, foreign-key stamping and sequence renumbering this class exists to guarantee; and
186
+ * `Symbol.species` would hand `map`/`filter` this constructor, which takes an owner and options
187
+ * rather than a length. Iterability adds the ergonomics without any of that.
188
+ *
189
+ * Use {@link Items} when you want the array itself — `map`, `filter`, `find` and indexing.
190
+ * It is `readonly`, which is what keeps a caller from mutating around the collection's back.
191
+ *
192
+ * @returns An iterator over the retained records, in collection order.
193
+ */
194
+ [Symbol.iterator]() {
195
+ return this.Items[Symbol.iterator]();
196
+ }
197
+ /**
198
+ * Alias for {@link Count}, so the collection reads like a collection in the places people expect
199
+ * `length`. Both go through {@link Items}, so both trigger a lazy fill and see a live cache view.
200
+ */
201
+ get length() {
202
+ return this.Count;
203
+ }
204
+ /**
205
+ * Children removed since the last load or save, awaiting deletion on the next save.
206
+ *
207
+ * Always empty when {@link RemovalMode} is `'orphan'`.
208
+ */
209
+ get Removed() {
210
+ return this.removed;
211
+ }
212
+ /**
213
+ * Number of retained related records.
214
+ *
215
+ * Deliberately delegates to {@link Items} rather than reading the backing array: for a `'lazy'`
216
+ * collection `Items` is what triggers population, so reading the raw array here would report 0
217
+ * for a collection that has simply not been touched yet — and `Count === 0` while
218
+ * `Items.length === 2` is the kind of inconsistency nobody debugs quickly. Same reason it picks
219
+ * up a live cache view's refresh.
220
+ */
221
+ get Count() {
222
+ return this.Items.length;
223
+ }
224
+ /** Whether this collection has been populated from the database. */
225
+ get IsLoaded() {
226
+ return this.loaded;
227
+ }
228
+ /**
229
+ * True when saving would produce work: any retained child is dirty or unsaved, or any removal
230
+ * is pending.
231
+ */
232
+ get Dirty() {
233
+ // A read-only collection never reports dirty, and that is load-bearing rather than tidy:
234
+ // a cache-sourced collection holds the ENGINE's entity instances, so a record dirtied by
235
+ // some unrelated code path would otherwise make every parent holding it claim it needs
236
+ // saving. Read-only collections contribute no save work either — see ContributeSaveWork.
237
+ if (this.IsReadOnly) {
238
+ return false;
239
+ }
240
+ if (this.removed.length > 0) {
241
+ return true;
242
+ }
243
+ return this.items.some(i => i.Dirty || !i.IsSaved);
244
+ }
245
+ /**
246
+ * Appends an existing child entity to the collection.
247
+ *
248
+ * The foreign key is *not* set here — it is stamped at save time, because when the parent is
249
+ * itself new its primary key does not exist yet. See {@link ContributeSaveWork}.
250
+ *
251
+ * @param item - The child to append.
252
+ * @returns The same child, for chaining.
253
+ */
254
+ Add(item) {
255
+ this.assertMutable('Add');
256
+ if (!item) {
257
+ throw new Error(`RelatedRecordCollection '${this.Name}': cannot add a null related record.`);
258
+ }
259
+ this.items.push(item);
260
+ this.applySequence();
261
+ this.stampParentKey();
262
+ return item;
263
+ }
264
+ /**
265
+ * Creates a new, empty child entity, appends it, and returns it.
266
+ *
267
+ * Uses the owner's provider so the child resolves to the right registered subclass on whichever
268
+ * tier this runs — the server subclass on the server, the shared subclass in the browser.
269
+ *
270
+ * @returns The newly created child.
271
+ */
272
+ async Create() {
273
+ this.assertMutable('Create');
274
+ const provider = this.Owner.ProviderToUse;
275
+ if (!provider) {
276
+ throw new Error(`RelatedRecordCollection '${this.Name}': owner has no provider; cannot create a child.`);
277
+ }
278
+ const child = await provider.GetEntityObject(this.RelatedEntityName, this.Owner.ContextCurrentUser);
279
+ child.NewRecord();
280
+ return this.Add(child);
281
+ }
282
+ /**
283
+ * Removes a child by instance or index.
284
+ *
285
+ * A child that was already persisted is queued for deletion when {@link RemovalMode} is
286
+ * `'delete'`; an unsaved child is simply dropped, since there is nothing to delete.
287
+ *
288
+ * @param itemOrIndex - The child instance, or its index in {@link Items}.
289
+ * @throws When {@link RemovalMode} is `'refuse'`.
290
+ */
291
+ Remove(itemOrIndex) {
292
+ this.assertMutable('Remove');
293
+ if (this.RemovalMode === 'refuse') {
294
+ throw new Error(`RelatedRecordCollection '${this.Name}' is declared OnRemove:'refuse' — children cannot be detached.`);
295
+ }
296
+ const index = typeof itemOrIndex === 'number' ? itemOrIndex : this.items.indexOf(itemOrIndex);
297
+ if (index < 0 || index >= this.items.length) {
298
+ return; // not present — removing something absent is a no-op, not an error
299
+ }
300
+ const [child] = this.items.splice(index, 1);
301
+ // Only a persisted child needs a delete. An unsaved one never reached the database, so
302
+ // queueing it would produce a delete against a primary key that does not exist.
303
+ if (child.IsSaved && this.RemovalMode === 'delete') {
304
+ this.removed.push(child);
305
+ }
306
+ this.applySequence();
307
+ }
308
+ /** Removes every child. */
309
+ Clear() {
310
+ this.assertMutable('Clear');
311
+ for (let i = this.items.length - 1; i >= 0; i--) {
312
+ this.Remove(i);
313
+ }
314
+ }
315
+ /**
316
+ * Populates the collection from the database.
317
+ *
318
+ * A no-op when the parent is unsaved (there is nothing to be a child of) or when
319
+ * {@link LoadMode} is `'never'`.
320
+ *
321
+ * @remarks
322
+ * A failed load **throws** rather than yielding an empty collection. Silently returning no
323
+ * children makes a populated parent look empty, and anything derived from that — a reversal, a
324
+ * total, a validation decision — is then wrong in a way nothing downstream can detect. Only
325
+ * saves use the boolean-return convention.
326
+ *
327
+ * @param force - Reload even if already loaded.
328
+ */
329
+ async Load(force = false) {
330
+ if (this.LoadMode === 'never') {
331
+ return;
332
+ }
333
+ if (!this.Owner.IsSaved) {
334
+ return;
335
+ }
336
+ if (this.loaded && !force) {
337
+ return;
338
+ }
339
+ // Cache first when declared. A hit costs zero queries; a miss falls straight through to the
340
+ // database load below, so a collection whose donor engine is not loaded yet still works.
341
+ if (this.Source === 'cache') {
342
+ const cached = this.findCachedRecords();
343
+ if (cached) {
344
+ this.SetLoadedItems(this.IsReadOnly ? cached : await this.copyRecords(cached));
345
+ return;
346
+ }
347
+ }
348
+ const provider = this.Owner.ProviderToUse;
349
+ if (!provider) {
350
+ throw new Error(`RelatedRecordCollection '${this.Name}': owner has no provider; cannot load.`);
351
+ }
352
+ const parentKey = this.Owner.FirstPrimaryKey?.Value;
353
+ const result = await provider.RunView({
354
+ EntityName: this.RelatedEntityName,
355
+ ExtraFilter: `${this.options.RelatedEntityJoinField} = '${String(parentKey).replace(/'/g, "''")}'`,
356
+ OrderBy: this.options.OrderBy,
357
+ ResultType: 'entity_object',
358
+ }, this.Owner.ContextCurrentUser);
359
+ if (!result.Success) {
360
+ throw new Error(`RelatedRecordCollection '${this.Name}': failed to load ${this.RelatedEntityName} for ` +
361
+ `${this.Owner.EntityInfo?.Name} ${String(parentKey)}: ${result.ErrorMessage ?? 'unknown error'}`);
362
+ }
363
+ this.items = result.Results ?? [];
364
+ this.removed = [];
365
+ this.loaded = true;
366
+ }
367
+ /** @inheritdoc */
368
+ async LoadEager() {
369
+ if (this.LoadMode === 'immediate') {
370
+ await this.Load();
371
+ }
372
+ }
373
+ /**
374
+ * Replaces the collection's contents with rows already fetched elsewhere.
375
+ *
376
+ * Used by `RunView`'s batched child loading, which issues one `WHERE fk IN (...)` for an entire
377
+ * result set and distributes the rows — turning what would be N+1 queries into 1 + K.
378
+ *
379
+ * @param items - The children belonging to this parent.
380
+ */
381
+ SetLoadedItems(items) {
382
+ this.items = items ?? [];
383
+ this.removed = [];
384
+ this.loaded = true;
385
+ }
386
+ /**
387
+ * Throws when the collection is read-only. Called by every mutating entry point.
388
+ *
389
+ * @param operation - The attempted operation, named in the error.
390
+ */
391
+ assertMutable(operation) {
392
+ if (this.IsReadOnly) {
393
+ throw new Error(`RelatedRecordCollection '${this.Name}' is read-only; ${operation} is not allowed. ` +
394
+ (this.Source === 'cache'
395
+ ? `It is sourced from a BaseEngine cache, so its records are shared instances owned by that ` +
396
+ `engine. Declare ReadOnly: false to get copies you can safely modify, or Source: 'database'.`
397
+ : `Declare ReadOnly: false to allow mutation.`));
398
+ }
399
+ }
400
+ /**
401
+ * Attempts to populate this collection from a `BaseEngine` cache without touching the database.
402
+ *
403
+ * Used by `BaseEntity.LoadRelatedRecords()` to resolve the free collections before batching
404
+ * whatever is left into a database round trip.
405
+ *
406
+ * @returns True when the collection was populated from a cache; false when the caller must load
407
+ * it from the database.
408
+ */
409
+ async TryLoadFromCache() {
410
+ if (this.Source !== 'cache' || (this.loaded && this.LoadMode !== 'never')) {
411
+ return this.Source === 'cache' && this.loaded;
412
+ }
413
+ const cached = this.findCachedRecords();
414
+ if (!cached) {
415
+ return false;
416
+ }
417
+ this.SetLoadedItems(this.IsReadOnly ? cached : await this.copyRecords(cached));
418
+ return true;
419
+ }
420
+ /**
421
+ * Fills the collection from whichever loaded engine already caches the related entity.
422
+ *
423
+ * Synchronous by nature — a registry walk plus a `filter` — which is what makes `'lazy'`
424
+ * possible at all. Returns `false` when no loaded engine offers the entity, leaving the
425
+ * collection unloaded so `Load()` can fall back to a query.
426
+ *
427
+ * @returns True when the collection was populated from a cache.
428
+ */
429
+ /**
430
+ * Re-reads a live cache view when the donor engine has moved on.
431
+ *
432
+ * Only applies to a read-only cache-sourced collection — the case where the records belong to
433
+ * the engine rather than to this collection. A writable cache collection holds COPIES the caller
434
+ * owns, so silently replacing them would discard their edits; and a database-sourced collection
435
+ * is a point-in-time load by definition, which is what callers expect of one.
436
+ *
437
+ * The check is two reference comparisons in the common case, so this stays cheap enough to run
438
+ * on every read.
439
+ */
440
+ refreshCacheViewIfStale() {
441
+ if (!this.cacheDonor || this.Source !== 'cache' || !this.IsReadOnly) {
442
+ return;
443
+ }
444
+ const current = this.cacheDonor.engine[this.cacheDonor.propertyName];
445
+ if (!Array.isArray(current)) {
446
+ return;
447
+ }
448
+ if (current === this.cacheDonor.array && current.length === this.cacheDonor.length) {
449
+ return; // unchanged
450
+ }
451
+ // Re-filter from the donor we already hold rather than re-walking the registry. The
452
+ // engine + property name IS the durable handle: reading the property fresh each time
453
+ // survives the engine reassigning it wholesale, which is the case a captured array
454
+ // reference misses. Re-running discovery here would also risk silently binding to a
455
+ // DIFFERENT engine mid-life if two happened to cache the same entity.
456
+ const parentKey = this.Owner.FirstPrimaryKey?.Value;
457
+ if (parentKey === null || parentKey === undefined || parentKey === '') {
458
+ return;
459
+ }
460
+ const joinField = this.RelatedEntityJoinField;
461
+ const records = current;
462
+ const mine = records.filter(r => UUIDsEqual(String(r.Get(joinField) ?? ''), String(parentKey)));
463
+ this.cacheDonor.array = records;
464
+ this.cacheDonor.length = records.length;
465
+ this.SetLoadedItems(this.sortLikeOrderBy(mine));
466
+ }
467
+ /**
468
+ * Populates a `'lazy'` collection from cache, or throws explaining why it could not.
469
+ *
470
+ * **A lazy declaration is an assertion.** Writing `Load: 'lazy'` says "an engine caches this
471
+ * entity"; there is no async fallback available from a getter, so if the assertion is wrong the
472
+ * only alternatives are a hard error or a silently empty array. Silence is how
473
+ * `MJAIAgentEntityExtended.Actions` returned `[]` to three call sites indefinitely without
474
+ * anyone noticing — exactly the failure this mechanism exists to end.
475
+ *
476
+ * A donor holding **zero rows** is a perfectly good answer and does not throw; the collection is
477
+ * simply empty. Only the absence of a donor is an error, and the message distinguishes the two
478
+ * ways that happens, because they need opposite fixes.
479
+ */
480
+ populateLazyOrThrow() {
481
+ if (this.populateFromCache()) {
482
+ return;
483
+ }
484
+ if (!this.Owner.IsSaved) {
485
+ return; // an unsaved parent owns no persisted related records; not an error
486
+ }
487
+ const declaringEngines = BaseEngineRegistry.Instance.FindEnginesDeclaringEntity(this.RelatedEntityName);
488
+ const prefix = `RelatedRecordCollection '${this.Name}' on ${this.Owner.EntityInfo?.Name} is declared Load: 'lazy'`;
489
+ if (declaringEngines.length > 0) {
490
+ // The cache exists — it just has not been populated yet. An ordering problem, and the
491
+ // caller can fix it by configuring the engine before reading the collection.
492
+ throw new Error(`${prefix}, but ${declaringEngines.join(' / ')} — which caches '${this.RelatedEntityName}' — ` +
493
+ `is not loaded yet. Await that engine's Config() before reading '${this.Name}', or declare ` +
494
+ `Load: 'explicit' and call LoadRelatedRecords() so it can fall back to the database.`);
495
+ }
496
+ // No REGISTERED engine declares the entity. This code cannot tell two very different
497
+ // situations apart, because engines only enter the registry once their Config() BEGINS:
498
+ // either the engine that caches this entity simply has not started loading yet (the common
499
+ // bootstrap ordering race — a component reads the collection before anything Config()s the
500
+ // engine), or nothing anywhere caches the entity and lazy can never work (a design error).
501
+ // Name both, with each fix — an error that diagnoses only one of them prescribes the wrong
502
+ // remedy half the time.
503
+ throw new Error(`${prefix}, but no registered BaseEngine declares '${this.RelatedEntityName}'. Two possible causes: ` +
504
+ `(1) the engine that caches it has not STARTED loading yet — engines register only once their ` +
505
+ `Config() begins, so await that engine's Config() before reading '${this.Name}' (this read is ` +
506
+ `safe to retry after it loads); or (2) nothing caches this entity at all — declare ` +
507
+ `Source: 'database' with Load: 'explicit', or add an entity config for ` +
508
+ `'${this.RelatedEntityName}' to an engine.`);
509
+ }
510
+ /**
511
+ * Whether reading {@link Items} right now will succeed — the guard for display-tier code.
512
+ *
513
+ * A lazy collection's {@link Items} getter **throws** when its donor engine is not available,
514
+ * deliberately: a silently empty array is how the bug this feature replaced went unnoticed for
515
+ * years. That is the right default for business logic, but a template or widget rendering
516
+ * during bootstrap (before anything has awaited the engine's `Config()`) wants "not yet",
517
+ * not an aborted render — and the null-check templates reach for (`@if (entity.Params && …)`)
518
+ * cannot help, because the collection property itself is never null; it is the *read* that
519
+ * throws.
520
+ *
521
+ * ```html
522
+ * @if (action.Params.IsAvailable) {
523
+ * @for (p of action.Params.Items; track p.ID) { … }
524
+ * }
525
+ * ```
526
+ *
527
+ * **This is a predicate, not a second way to read.** There is exactly one accessor — `Items` —
528
+ * so there is no `null`-versus-`[]` ambiguity for a caller to get wrong, and no quiet path that
529
+ * can drift into business logic and re-create the silent-empty bug. `true` here means the very
530
+ * next `Items` read is safe *and already populated*, because deciding the answer requires
531
+ * consulting the donor, and consulting it is what fills the collection.
532
+ *
533
+ * Never triggers a database load, and never throws.
534
+ */
535
+ get IsAvailable() {
536
+ if (this.loaded || this.LoadMode !== 'lazy') {
537
+ return true; // Items cannot throw on these paths
538
+ }
539
+ if (this.populateFromCache()) {
540
+ return true;
541
+ }
542
+ // An unsaved parent owns no persisted related records, so Items legitimately answers []
543
+ // rather than throwing — the donor being absent is irrelevant to it.
544
+ return !this.Owner.IsSaved;
545
+ }
546
+ populateFromCache() {
547
+ // Sharing is the only synchronous option: copying needs `GetEntityObject`, which is async.
548
+ // So the sync path is read-only-only, and a writable cache-backed collection must go through
549
+ // the async `Load()`. CodeGen enforces the matching rule that `lazy` implies read-only.
550
+ if (!this.IsReadOnly) {
551
+ return false;
552
+ }
553
+ const cached = this.findCachedRecords();
554
+ if (!cached) {
555
+ return false;
556
+ }
557
+ this.SetLoadedItems(cached);
558
+ return true;
559
+ }
560
+ /**
561
+ * Finds this record's related rows in whichever loaded engine already caches the related entity.
562
+ *
563
+ * @returns The matching records in declared order, or `null` when no loaded engine offers the
564
+ * entity — in which case the caller falls back to a database load.
565
+ */
566
+ findCachedRecords() {
567
+ const parentKey = this.Owner.FirstPrimaryKey?.Value;
568
+ if (parentKey === null || parentKey === undefined || parentKey === '') {
569
+ return null; // an unsaved parent owns no persisted related records
570
+ }
571
+ // `unfilteredOnly` matters: a donor whose config carries a Filter holds a SUBSET, which
572
+ // would silently give us an incomplete collection. `simple` donors are excluded because
573
+ // these records must be real BaseEntity instances.
574
+ const matches = BaseEngineRegistry.Instance.FindCachedEntity(this.RelatedEntityName, { unfilteredOnly: true });
575
+ const donor = matches.find(m => (m.config.ResultType ?? 'entity_object') !== 'simple');
576
+ if (!donor) {
577
+ if (IsVerboseLoggingEnabled()) {
578
+ LogStatus(`RelatedRecordCollection '${this.Name}': no loaded engine caches '${this.RelatedEntityName}' — ` +
579
+ `falling back to a database load.`);
580
+ }
581
+ return null;
582
+ }
583
+ // Remember where this came from so the collection can stay live rather than snapshotting.
584
+ const propertyName = donor.config.PropertyName;
585
+ if (propertyName) {
586
+ this.cacheDonor = {
587
+ engine: donor.engine,
588
+ propertyName,
589
+ array: donor.records,
590
+ length: donor.records.length,
591
+ };
592
+ }
593
+ const joinField = this.RelatedEntityJoinField;
594
+ const mine = donor.records.filter(r => UUIDsEqual(String(r.Get(joinField) ?? ''), String(parentKey)));
595
+ return this.sortLikeOrderBy(mine);
596
+ }
597
+ /**
598
+ * Applies the declared `OrderBy` to cache-sourced records.
599
+ *
600
+ * Only single-field `FIELD [ASC|DESC]` clauses are honored — the common case, and all that can
601
+ * be done in memory without reimplementing SQL. Anything more complex is left in donor order
602
+ * rather than half-applied, because a silently mis-ordered sequenced collection would renumber
603
+ * itself into that wrong order on the next mutation.
604
+ *
605
+ * @param records - The filtered records.
606
+ * @returns A new, ordered array.
607
+ */
608
+ sortLikeOrderBy(records) {
609
+ const terms = this.parseOrderBy();
610
+ if (terms.length === 0) {
611
+ return [...records];
612
+ }
613
+ return [...records].sort((a, b) => {
614
+ // Compare term by term, stopping at the first that discriminates — the ordinary
615
+ // multi-key sort, so 'Priority ASC, Name ASC' means what it says rather than being
616
+ // silently reduced to the first field.
617
+ for (const { field, sign } of terms) {
618
+ const av = a.Get(field);
619
+ const bv = b.Get(field);
620
+ if (av === bv) {
621
+ continue;
622
+ }
623
+ if (av === null || av === undefined)
624
+ return -sign;
625
+ if (bv === null || bv === undefined)
626
+ return sign;
627
+ return (av < bv ? -1 : 1) * sign;
628
+ }
629
+ return 0;
630
+ });
631
+ }
632
+ /**
633
+ * Parses the declared `OrderBy` into comparable terms.
634
+ *
635
+ * Handles `FIELD [ASC|DESC]` lists — `'Priority ASC, Name DESC'`. Anything beyond that (an
636
+ * expression, a function call, a CASE) is refused wholesale rather than partially applied,
637
+ * because a *silently* mis-ordered sequenced collection renumbers itself into that wrong order
638
+ * on the next mutation. This is in-memory ordering for cache-sourced collections only; a
639
+ * database-sourced load passes the clause to SQL untouched.
640
+ *
641
+ * @returns One term per field, or an empty array when the clause cannot be honored in memory.
642
+ */
643
+ parseOrderBy() {
644
+ const clause = this.OrderByClause?.trim();
645
+ if (!clause) {
646
+ return [];
647
+ }
648
+ const terms = [];
649
+ for (const raw of clause.split(',')) {
650
+ const parts = raw.trim().split(/\s+/).filter(Boolean);
651
+ if (parts.length === 0 || parts.length > 2) {
652
+ return []; // not a plain field list — do not half-apply it
653
+ }
654
+ const [field, direction] = parts;
655
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(field)) {
656
+ return []; // an expression rather than a column
657
+ }
658
+ if (direction && !['ASC', 'DESC'].includes(direction.toUpperCase())) {
659
+ return [];
660
+ }
661
+ terms.push({ field, sign: (direction ?? 'ASC').toUpperCase() === 'DESC' ? -1 : 1 });
662
+ }
663
+ return terms;
664
+ }
665
+ /**
666
+ * Copies cached records into fresh entity instances, so a writable collection never hands out —
667
+ * or mutates — the engine's own objects. Saving a copy fires the ordinary `BaseEntity` save
668
+ * event that the engines already subscribe to, so their caches refresh themselves.
669
+ *
670
+ * @param records - The engine's cached records.
671
+ * @returns Detached copies carrying the same field values.
672
+ */
673
+ async copyRecords(records) {
674
+ const provider = this.Owner.ProviderToUse;
675
+ const copies = [];
676
+ for (const source of records) {
677
+ const copy = await provider?.GetEntityObject(this.RelatedEntityName, this.Owner.ContextCurrentUser);
678
+ if (!copy) {
679
+ // Silently sharing here would defeat the entire reason this collection is writable:
680
+ // the caller asked for records it can modify WITHOUT touching the engine's cache,
681
+ // and handing back shared instances would let it corrupt that cache invisibly.
682
+ throw new Error(`RelatedRecordCollection '${this.Name}': cannot materialize copies of ` +
683
+ `'${this.RelatedEntityName}' because no entity factory is available on the provider. ` +
684
+ `A writable cache-sourced collection must copy; declare ReadOnly: true to share the ` +
685
+ `engine's instances instead, or Source: 'database' to load fresh ones.`);
686
+ }
687
+ if (IsVerboseLoggingEnabled() && source.Dirty) {
688
+ // A copy adopts the source's CURRENT values as its clean baseline, so uncommitted
689
+ // edits made to the cached instance by unrelated code silently become "persisted
690
+ // state" from the copy's point of view. Rare, but worth a witness.
691
+ LogStatus(`RelatedRecordCollection '${this.Name}': copying a '${this.RelatedEntityName}' record that has ` +
692
+ `unsaved changes in the engine cache — the copy treats those uncommitted values as saved.`);
693
+ }
694
+ const data = source.GetAll();
695
+ for (const key of Object.keys(data)) {
696
+ // GetAll() passes existing Date instances through BY REFERENCE. Without cloning,
697
+ // the "detached" copy would alias the engine cache's Date objects, and an in-place
698
+ // mutation (setHours and friends) would leak across the copy boundary.
699
+ if (data[key] instanceof Date) {
700
+ data[key] = new Date(data[key].getTime());
701
+ }
702
+ }
703
+ await copy.LoadFromData(data, true);
704
+ copies.push(copy);
705
+ }
706
+ return copies;
707
+ }
708
+ /** @inheritdoc */
709
+ Validate(result) {
710
+ // A read-only collection is a projection over records this parent does not own — most
711
+ // commonly a BaseEngine cache's shared instances. It contributes no save work (see
712
+ // ContributeSaveWork), so it must not contribute validation failures either: an invalid
713
+ // record sitting in an engine cache is not this parent's problem, and blocking the parent's
714
+ // save on it would block a write the parent is not even attempting. More importantly,
715
+ // stampParentKey() below MUTATES the records — on shared cache instances that dirties them
716
+ // for every holder in the process whenever the stored key differs (UUID casing, most
717
+ // commonly), which is exactly the corruption the read-only contract exists to prevent.
718
+ if (this.IsReadOnly) {
719
+ return;
720
+ }
721
+ // Stamp the foreign key BEFORE validating. Most MJ primary keys are UUIDs generated by
722
+ // NewRecord(), so the parent's key exists well before its row does — but the related record
723
+ // has not been told about it yet. Validating first would fail every `NOT NULL` foreign key
724
+ // on a create, reporting "OrderHeaderID cannot be null" for a graph that is perfectly valid
725
+ // and about to be saved correctly.
726
+ this.stampParentKey();
727
+ for (const [index, child] of this.items.entries()) {
728
+ const childResult = child.Validate();
729
+ if (!childResult.Success) {
730
+ result.Success = false;
731
+ result.Errors.push(...this.prefixErrors(childResult.Errors, index));
732
+ }
733
+ }
734
+ }
735
+ /** @inheritdoc */
736
+ async ValidateAsync(result) {
737
+ if (this.IsReadOnly) {
738
+ return; // see the note in Validate() — a projection neither validates nor mutates
739
+ }
740
+ this.stampParentKey(); // see the note in Validate()
741
+ for (const [index, child] of this.items.entries()) {
742
+ const childResult = await child.ValidateAsync();
743
+ if (!childResult.Success) {
744
+ result.Success = false;
745
+ result.Errors.push(...this.prefixErrors(childResult.Errors, index));
746
+ }
747
+ }
748
+ }
749
+ /**
750
+ * Prefixes child validation errors with the collection and index they came from.
751
+ *
752
+ * Without this a failing order line reports "Quantity is required" with no indication of *which*
753
+ * line, which is close to useless on a twenty-line order.
754
+ *
755
+ * @param errors - The child's raw validation errors.
756
+ * @param index - The child's position in the collection.
757
+ * @returns Re-labeled errors.
758
+ */
759
+ prefixErrors(errors, index) {
760
+ return errors.map(e => new ValidationErrorInfo(`${this.Name}[${index}].${e.Source ?? ''}`.replace(/\.$/, ''), e.Message, e.Value, e.Type ?? ValidationErrorType.Failure));
761
+ }
762
+ /** @inheritdoc */
763
+ ContributeSaveWork(plan, options) {
764
+ // A read-only collection is a projection, not a unit of work. Contributing nothing is what
765
+ // makes it safe to point one at an engine's shared cache.
766
+ if (this.IsReadOnly) {
767
+ this.warnIfReadOnlyHoldsDirtyItems();
768
+ return;
769
+ }
770
+ // Deletions first: a removed child may hold a unique key that a retained one is about to
771
+ // take (a re-sequenced LineNumber, most commonly). Freeing it before the inserts run avoids
772
+ // a spurious constraint violation on what is a perfectly legal edit.
773
+ for (const [index, child] of this.removed.entries()) {
774
+ plan.AddDelete(child, `${this.Name}.Removed[${index}]`);
775
+ }
776
+ for (const [index, child] of this.items.entries()) {
777
+ // A clean, already-persisted child contributes no work. Enqueueing it anyway turned a
778
+ // header-only edit on a parent with 50 loaded lines into a 51-node graph — locally 50
779
+ // no-op saves inside a needless transaction, and on the remote path 50 rows shipped,
780
+ // server-side re-loaded one query each, and shipped back, all for zero writes. Safe to
781
+ // skip because renumbered children are already dirty when the plan is built
782
+ // (applySequence runs at Add/Remove time) and removals contribute via `removed` above.
783
+ // IgnoreDirtyState demands a full write-out, so it re-enqueues everything.
784
+ if (child.IsSaved && !child.Dirty && !options?.IgnoreDirtyState) {
785
+ continue;
786
+ }
787
+ // Re-stamp at EXECUTION time as well as at add/validate time. For UUID keys the value
788
+ // is already there; for identity/auto-increment keys the parent's key does not exist
789
+ // until its own node has run, and this is the first moment it does.
790
+ plan.AddSave(child, `${this.Name}[${index}]`, () => this.stampParentKey());
791
+ }
792
+ }
793
+ /**
794
+ * Leaves the exact breadcrumb a developer debugging "my edit vanished" needs.
795
+ *
796
+ * Mutating a record obtained from a read-only collection and then saving the PARENT succeeds
797
+ * while persisting nothing — the collection contributes no save work by design, and `Dirty`
798
+ * deliberately excludes it. That is correct, documented behavior, but it emits no runtime
799
+ * signal at all; this verbose-mode log is the one witness. Save the record directly
800
+ * (`record.Save()`), or declare the collection writable, to persist such edits.
801
+ */
802
+ warnIfReadOnlyHoldsDirtyItems() {
803
+ if (!IsVerboseLoggingEnabled()) {
804
+ return;
805
+ }
806
+ if (this.items.some(i => i.Dirty)) {
807
+ LogStatus(`RelatedRecordCollection '${this.Name}' on ${this.Owner.EntityInfo?.Name} is read-only but holds ` +
808
+ `dirty record(s). A read-only collection never saves its records with the parent, so those edits ` +
809
+ `will NOT persist via the parent's Save(). Save each record directly, or declare the collection ` +
810
+ `ReadOnly: false (copies) or Source: 'database' (fresh rows) to make it writable.`);
811
+ }
812
+ }
813
+ /** @inheritdoc */
814
+ ContributeDeleteWork(plan) {
815
+ if (this.IsReadOnly) {
816
+ return; // a projection never cascades a delete
817
+ }
818
+ if (this.RemovalMode !== 'delete') {
819
+ return; // aggregation, or refusal — the parent's removal does not imply the child's
820
+ }
821
+ // Children before the parent: the foreign key points at the row about to disappear.
822
+ for (const [index, child] of this.items.entries()) {
823
+ plan.AddDelete(child, `${this.Name}[${index}]`);
824
+ }
825
+ for (const [index, child] of this.removed.entries()) {
826
+ plan.AddDelete(child, `${this.Name}.Removed[${index}]`);
827
+ }
828
+ }
829
+ /** @inheritdoc */
830
+ AcceptChanges() {
831
+ this.removed = [];
832
+ if (this.options.ClearAfterSave) {
833
+ this.items = [];
834
+ this.loaded = false;
835
+ }
836
+ }
837
+ /** @inheritdoc */
838
+ async Serialize() {
839
+ // A read-only collection ships nothing, for the same reason it contributes no save work:
840
+ // it is a projection over records this parent does not own. Serializing it put the donor
841
+ // engine's ENTIRE cached child set on the wire with every graph save and TransactionGroup
842
+ // envelope, and the receiving tier's 'request'-mode Deserialize then re-loaded every one of
843
+ // those rows from the database — one query each — and failed the whole save if any cached
844
+ // row had been concurrently deleted. All of it for records the save will never write.
845
+ if (this.IsReadOnly) {
846
+ return null;
847
+ }
848
+ // Nothing pending means nothing to ship. Sending an empty collection on every header-only
849
+ // save would be pure overhead on the hot path.
850
+ if (this.items.length === 0 && this.removed.length === 0) {
851
+ return null;
852
+ }
853
+ return {
854
+ Items: this.items.map(i => ({ Fields: i.GetAll(), IsNew: !i.IsSaved })),
855
+ Removed: this.removed.map(r => this.primaryKeyOf(r)),
856
+ };
857
+ }
858
+ /** @inheritdoc */
859
+ async Deserialize(data, mode = 'request') {
860
+ const provider = this.Owner.ProviderToUse;
861
+ if (!provider) {
862
+ throw new Error(`RelatedRecordCollection '${this.Name}': owner has no provider; cannot deserialize.`);
863
+ }
864
+ this.items = await this.rehydrateItems(provider, data?.Items ?? [], mode);
865
+ // Removals only exist in a request — a result describes what survived, and anything deleted
866
+ // is simply absent from it.
867
+ this.removed = mode === 'request' ? await this.rehydrateRemovals(provider, data?.Removed ?? []) : [];
868
+ this.loaded = true;
869
+ }
870
+ /**
871
+ * Rebuilds retained child entity objects from the wire.
872
+ *
873
+ * Each child is created through the provider, so it resolves to whatever subclass is registered
874
+ * on **this** tier. That is what makes a graph assembled in the browser execute server-side
875
+ * business logic: the server rebuilds the same records as their server subclasses.
876
+ *
877
+ * @param provider - The provider to create entity objects from.
878
+ * @param rows - Wire items.
879
+ * @returns Rehydrated child entities.
880
+ */
881
+ async rehydrateItems(provider, rows, mode) {
882
+ const out = [];
883
+ for (const row of rows) {
884
+ const child = await provider.GetEntityObject(this.RelatedEntityName, this.Owner.ContextCurrentUser);
885
+ if (mode === 'result') {
886
+ // Authoritative post-save state: adopt it verbatim and land clean. No query — the
887
+ // sender just persisted these rows and is telling us what they now contain.
888
+ await child.LoadFromData(row.Fields, true);
889
+ out.push(child);
890
+ continue;
891
+ }
892
+ if (row.IsNew) {
893
+ child.NewRecord();
894
+ child.SetMany(row.Fields, true);
895
+ }
896
+ else {
897
+ // AN EXISTING CHILD MUST BE LOADED BEFORE THE WIRE VALUES ARE APPLIED.
898
+ //
899
+ // The tempting shortcut — LoadFromData(row, replaceOldValues: true) — sets each
900
+ // field's OLD value to the value that arrived over the wire. The record then reports
901
+ // Dirty === false, its Save() takes the not-dirty early return, and the caller's edit
902
+ // is silently discarded while every layer reports success.
903
+ //
904
+ // Loading first gives genuine old values, so dirty tracking is accurate and the
905
+ // old-values concurrency check has something real to compare against. It costs one
906
+ // query per edited child; correctness first, and a batched variant can follow.
907
+ const key = new CompositeKey(child.EntityInfo.PrimaryKeys.map(pk => new KeyValuePair(pk.Name, row.Fields[pk.Name])));
908
+ const loaded = await child.InnerLoad(key);
909
+ if (!loaded) {
910
+ throw new Error(`RelatedRecordCollection '${this.Name}': cannot load existing ${this.RelatedEntityName} ` +
911
+ `record ${key.ToString()} referenced by the incoming payload.`);
912
+ }
913
+ child.SetMany(row.Fields, true);
914
+ }
915
+ out.push(child);
916
+ }
917
+ return out;
918
+ }
919
+ /**
920
+ * Rebuilds the children queued for deletion. Only identity is carried, so these are loaded from
921
+ * their primary keys — a delete needs the real row, not the sender's view of its fields.
922
+ *
923
+ * A removal whose row has already vanished is skipped rather than failing the graph: the intent
924
+ * ("this should not exist") is already satisfied.
925
+ *
926
+ * @param provider - The provider to create entity objects from.
927
+ * @param rows - Primary-key maps.
928
+ * @returns Loaded child entities to delete.
929
+ */
930
+ async rehydrateRemovals(provider, rows) {
931
+ const out = [];
932
+ for (const row of rows) {
933
+ const child = await provider.GetEntityObject(this.RelatedEntityName, this.Owner.ContextCurrentUser);
934
+ const key = new CompositeKey(child.EntityInfo.PrimaryKeys.map(pk => new KeyValuePair(pk.Name, row[pk.Name])));
935
+ if (await child.InnerLoad(key)) {
936
+ out.push(child);
937
+ }
938
+ }
939
+ return out;
940
+ }
941
+ /**
942
+ * Extracts just the primary-key fields of a child, for the `Removed` payload.
943
+ *
944
+ * Removals only need identity — shipping the whole row would waste bandwidth and invite the
945
+ * server to act on stale field values for a record it is about to delete.
946
+ *
947
+ * @param child - The removed child.
948
+ * @returns A map of primary-key field names to values.
949
+ */
950
+ primaryKeyOf(child) {
951
+ const out = {};
952
+ for (const pk of child.EntityInfo?.PrimaryKeys ?? []) {
953
+ out[pk.Name] = child.Get(pk.Name);
954
+ }
955
+ return out;
956
+ }
957
+ /**
958
+ * Copies the parent's primary key into every retained record's foreign-key field.
959
+ *
960
+ * Called at add time, at validation time and again immediately before each record is written.
961
+ * Doing it early keeps the in-memory graph coherent — `line.OrderHeaderID` is populated as soon
962
+ * as the line is added, which is what a caller inspecting the object expects, and what lets a
963
+ * `NOT NULL` foreign key pass validation on a create. Doing it again at execution time covers
964
+ * identity/auto-increment parents, whose key genuinely does not exist until their row is
965
+ * inserted.
966
+ *
967
+ * A parent with no key yet is skipped rather than stamping `undefined` over a value that may
968
+ * already be correct.
969
+ */
970
+ stampParentKey() {
971
+ const parentKey = this.Owner.FirstPrimaryKey?.Value;
972
+ if (parentKey === null || parentKey === undefined || parentKey === '') {
973
+ return;
974
+ }
975
+ for (const child of this.items) {
976
+ child.Set(this.options.RelatedEntityJoinField, parentKey);
977
+ }
978
+ }
979
+ /**
980
+ * Renumbers retained children when the collection declares a sequence field.
981
+ *
982
+ * Runs on every add and remove so the sequence is always contiguous and gap-free, which is what
983
+ * callers assume when they display or reference "line 3".
984
+ */
985
+ applySequence() {
986
+ const seq = this.options.Sequence;
987
+ if (!seq) {
988
+ return;
989
+ }
990
+ const from = seq.From ?? 1;
991
+ this.items.forEach((child, index) => {
992
+ try {
993
+ child.Set(seq.Field, from + index);
994
+ }
995
+ catch (e) {
996
+ // A misdeclared sequence field must not take the whole save down; surface it loudly
997
+ // and let validation report the real problem.
998
+ LogError(`RelatedRecordCollection '${this.Name}': cannot set sequence field '${seq.Field}' on ` +
999
+ `${this.RelatedEntityName}: ${e instanceof Error ? e.message : String(e)}`);
1000
+ }
1001
+ });
1002
+ }
1003
+ }
1004
+ //# sourceMappingURL=relatedRecordCollection.js.map