@objectstack/runtime 3.2.1 → 3.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,713 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { IDataEngine, IMetadataService, ISeedLoaderService } from '@objectstack/spec/contracts';
4
+ import type {
5
+ SeedLoaderRequest,
6
+ SeedLoaderResult,
7
+ SeedLoaderConfig,
8
+ SeedLoaderConfigInput,
9
+ ObjectDependencyGraph,
10
+ ObjectDependencyNode,
11
+ ReferenceResolution,
12
+ ReferenceResolutionError,
13
+ DatasetLoadResult,
14
+ Dataset,
15
+ } from '@objectstack/spec/data';
16
+
17
+ interface Logger {
18
+ info(message: string, meta?: Record<string, any>): void;
19
+ warn(message: string, meta?: Record<string, any>): void;
20
+ error(message: string, error?: Error, meta?: Record<string, any>): void;
21
+ debug(message: string, meta?: Record<string, any>): void;
22
+ }
23
+
24
+ /** Default field used for externalId matching on target objects */
25
+ const DEFAULT_EXTERNAL_ID_FIELD = 'name';
26
+
27
+ /**
28
+ * SeedLoaderService — Runtime implementation of ISeedLoaderService
29
+ *
30
+ * Provides metadata-driven seed data loading with:
31
+ * - Automatic lookup/master_detail reference resolution via externalId
32
+ * - Topological dependency ordering (parents before children)
33
+ * - Multi-pass loading for circular references
34
+ * - Dry-run validation mode
35
+ * - Upsert support honoring DatasetSchema mode
36
+ * - Actionable error reporting
37
+ */
38
+ export class SeedLoaderService implements ISeedLoaderService {
39
+ private engine: IDataEngine;
40
+ private metadata: IMetadataService;
41
+ private logger: Logger;
42
+
43
+ constructor(engine: IDataEngine, metadata: IMetadataService, logger: Logger) {
44
+ this.engine = engine;
45
+ this.metadata = metadata;
46
+ this.logger = logger;
47
+ }
48
+
49
+ // ==========================================================================
50
+ // Public API
51
+ // ==========================================================================
52
+
53
+ async load(request: SeedLoaderRequest): Promise<SeedLoaderResult> {
54
+ const startTime = Date.now();
55
+ const config = request.config;
56
+ const allErrors: ReferenceResolutionError[] = [];
57
+ const allResults: DatasetLoadResult[] = [];
58
+
59
+ // 1. Filter datasets by environment
60
+ const datasets = this.filterByEnv(request.datasets, config.env);
61
+
62
+ if (datasets.length === 0) {
63
+ return this.buildEmptyResult(config, Date.now() - startTime);
64
+ }
65
+
66
+ // 2. Build dependency graph
67
+ const objectNames = datasets.map(d => d.object);
68
+ const graph = await this.buildDependencyGraph(objectNames);
69
+
70
+ this.logger.info('[SeedLoader] Dependency graph built', {
71
+ objects: objectNames.length,
72
+ insertOrder: graph.insertOrder,
73
+ circularDeps: graph.circularDependencies.length,
74
+ });
75
+
76
+ // 3. Order datasets by topological insert order
77
+ const orderedDatasets = this.orderDatasets(datasets, graph.insertOrder);
78
+
79
+ // 4. Build reference lookup map from metadata (field → target object)
80
+ const refMap = this.buildReferenceMap(graph);
81
+
82
+ // 5. Pass 1: Insert/upsert records, resolving references
83
+ const insertedRecords = new Map<string, Map<string, string>>(); // object → externalIdValue → internalId
84
+ const deferredUpdates: DeferredUpdate[] = [];
85
+
86
+ for (const dataset of orderedDatasets) {
87
+ const result = await this.loadDataset(
88
+ dataset, config, refMap, insertedRecords, deferredUpdates, allErrors
89
+ );
90
+ allResults.push(result);
91
+
92
+ if (config.haltOnError && result.errored > 0) {
93
+ this.logger.warn('[SeedLoader] Halting on first error', { object: dataset.object });
94
+ break;
95
+ }
96
+ }
97
+
98
+ // 6. Pass 2: Resolve deferred references (circular dependencies)
99
+ if (config.multiPass && deferredUpdates.length > 0 && !config.dryRun) {
100
+ this.logger.info('[SeedLoader] Pass 2: resolving deferred references', {
101
+ count: deferredUpdates.length,
102
+ });
103
+ await this.resolveDeferredUpdates(deferredUpdates, insertedRecords, allResults, allErrors);
104
+ }
105
+
106
+ // 7. Build final result
107
+ const durationMs = Date.now() - startTime;
108
+ return this.buildResult(config, graph, allResults, allErrors, durationMs);
109
+ }
110
+
111
+ async buildDependencyGraph(objectNames: string[]): Promise<ObjectDependencyGraph> {
112
+ const nodes: ObjectDependencyNode[] = [];
113
+ const objectSet = new Set(objectNames);
114
+
115
+ for (const objectName of objectNames) {
116
+ const objDef = await this.metadata.getObject(objectName) as any;
117
+ const dependsOn: string[] = [];
118
+ const references: ReferenceResolution[] = [];
119
+
120
+ if (objDef && objDef.fields) {
121
+ const fields = objDef.fields as Record<string, any>;
122
+ for (const [fieldName, fieldDef] of Object.entries(fields)) {
123
+ if (
124
+ (fieldDef.type === 'lookup' || fieldDef.type === 'master_detail') &&
125
+ fieldDef.reference
126
+ ) {
127
+ const targetObject = fieldDef.reference as string;
128
+
129
+ // Track dependency ordering only for objects within the graph
130
+ if (objectSet.has(targetObject) && !dependsOn.includes(targetObject)) {
131
+ dependsOn.push(targetObject);
132
+ }
133
+
134
+ // Track ALL references for resolution (target may exist in database)
135
+ references.push({
136
+ field: fieldName,
137
+ targetObject,
138
+ targetField: DEFAULT_EXTERNAL_ID_FIELD,
139
+ fieldType: fieldDef.type as 'lookup' | 'master_detail',
140
+ });
141
+ }
142
+ }
143
+ }
144
+
145
+ nodes.push({ object: objectName, dependsOn, references });
146
+ }
147
+
148
+ // Topological sort
149
+ const { insertOrder, circularDependencies } = this.topologicalSort(nodes);
150
+
151
+ return { nodes, insertOrder, circularDependencies };
152
+ }
153
+
154
+ async validate(datasets: Dataset[], config?: SeedLoaderConfigInput): Promise<SeedLoaderResult> {
155
+ const { SeedLoaderConfigSchema } = await import('@objectstack/spec/data');
156
+ const parsedConfig = SeedLoaderConfigSchema.parse({ ...config, dryRun: true });
157
+ return this.load({ datasets, config: parsedConfig });
158
+ }
159
+
160
+ // ==========================================================================
161
+ // Internal: Dataset Loading
162
+ // ==========================================================================
163
+
164
+ private async loadDataset(
165
+ dataset: Dataset,
166
+ config: SeedLoaderConfig,
167
+ refMap: Map<string, ReferenceResolution[]>,
168
+ insertedRecords: Map<string, Map<string, string>>,
169
+ deferredUpdates: DeferredUpdate[],
170
+ allErrors: ReferenceResolutionError[],
171
+ ): Promise<DatasetLoadResult> {
172
+ const objectName = dataset.object;
173
+ const mode = dataset.mode || config.defaultMode;
174
+ const externalId = dataset.externalId || 'name';
175
+
176
+ let inserted = 0;
177
+ let updated = 0;
178
+ let skipped = 0;
179
+ let errored = 0;
180
+ let referencesResolved = 0;
181
+ let referencesDeferred = 0;
182
+ const errors: ReferenceResolutionError[] = [];
183
+
184
+ // Ensure the object's record map exists
185
+ if (!insertedRecords.has(objectName)) {
186
+ insertedRecords.set(objectName, new Map());
187
+ }
188
+
189
+ // Pre-load existing records for upsert matching
190
+ let existingRecords: Map<string, any> | undefined;
191
+ if ((mode === 'upsert' || mode === 'update' || mode === 'ignore') && !config.dryRun) {
192
+ existingRecords = await this.loadExistingRecords(objectName, externalId);
193
+ }
194
+
195
+ // Get reference resolutions for this object
196
+ const objectRefs = refMap.get(objectName) || [];
197
+
198
+ for (let i = 0; i < dataset.records.length; i++) {
199
+ const record = { ...dataset.records[i] }; // Clone to avoid mutation
200
+
201
+ // Resolve references
202
+ for (const ref of objectRefs) {
203
+ const fieldValue = record[ref.field];
204
+ if (fieldValue === undefined || fieldValue === null) continue;
205
+
206
+ // Skip if value looks like an internal ID (not a natural key)
207
+ if (typeof fieldValue !== 'string' || this.looksLikeInternalId(fieldValue)) continue;
208
+
209
+ // Try to resolve via already-inserted records
210
+ const targetMap = insertedRecords.get(ref.targetObject);
211
+ const resolvedId = targetMap?.get(String(fieldValue));
212
+
213
+ if (resolvedId) {
214
+ record[ref.field] = resolvedId;
215
+ referencesResolved++;
216
+ } else if (!config.dryRun) {
217
+ // Try to resolve from existing data in the database
218
+ const dbId = await this.resolveFromDatabase(ref.targetObject, ref.targetField, fieldValue);
219
+ if (dbId) {
220
+ record[ref.field] = dbId;
221
+ referencesResolved++;
222
+ } else if (config.multiPass) {
223
+ // Defer to pass 2
224
+ record[ref.field] = null;
225
+ deferredUpdates.push({
226
+ objectName,
227
+ recordExternalId: String(record[externalId] ?? ''),
228
+ field: ref.field,
229
+ targetObject: ref.targetObject,
230
+ targetField: ref.targetField,
231
+ attemptedValue: fieldValue,
232
+ recordIndex: i,
233
+ });
234
+ referencesDeferred++;
235
+ } else {
236
+ // Cannot resolve - record error
237
+ const error: ReferenceResolutionError = {
238
+ sourceObject: objectName,
239
+ field: ref.field,
240
+ targetObject: ref.targetObject,
241
+ targetField: ref.targetField,
242
+ attemptedValue: fieldValue,
243
+ recordIndex: i,
244
+ message: `Cannot resolve reference: ${objectName}.${ref.field} = '${fieldValue}' → ${ref.targetObject}.${ref.targetField} not found`,
245
+ };
246
+ errors.push(error);
247
+ allErrors.push(error);
248
+ }
249
+ } else {
250
+ // Dry-run: attempt resolution, report error if not found
251
+ const targetMap2 = insertedRecords.get(ref.targetObject);
252
+ if (!targetMap2?.has(String(fieldValue))) {
253
+ const error: ReferenceResolutionError = {
254
+ sourceObject: objectName,
255
+ field: ref.field,
256
+ targetObject: ref.targetObject,
257
+ targetField: ref.targetField,
258
+ attemptedValue: fieldValue,
259
+ recordIndex: i,
260
+ message: `[dry-run] Reference may not resolve: ${objectName}.${ref.field} = '${fieldValue}' → ${ref.targetObject}.${ref.targetField}`,
261
+ };
262
+ errors.push(error);
263
+ allErrors.push(error);
264
+ }
265
+ }
266
+ }
267
+
268
+ // Insert/upsert the record
269
+ if (!config.dryRun) {
270
+ try {
271
+ const result = await this.writeRecord(
272
+ objectName, record, mode, externalId, existingRecords
273
+ );
274
+
275
+ if (result.action === 'inserted') inserted++;
276
+ else if (result.action === 'updated') updated++;
277
+ else if (result.action === 'skipped') skipped++;
278
+
279
+ // Track the inserted/updated record's ID for reference resolution
280
+ const externalIdValue = String(record[externalId] ?? '');
281
+ const internalId = result.id;
282
+ if (externalIdValue && internalId) {
283
+ insertedRecords.get(objectName)!.set(externalIdValue, String(internalId));
284
+ }
285
+ } catch (err: any) {
286
+ errored++;
287
+ this.logger.warn(`[SeedLoader] Failed to write ${objectName} record`, {
288
+ error: err.message,
289
+ recordIndex: i,
290
+ });
291
+ }
292
+ } else {
293
+ // Dry-run: simulate insert tracking
294
+ const externalIdValue = String(record[externalId] ?? '');
295
+ if (externalIdValue) {
296
+ insertedRecords.get(objectName)!.set(externalIdValue, `dry-run-id-${i}`);
297
+ }
298
+ inserted++; // Count as "would be inserted"
299
+ }
300
+ }
301
+
302
+ return {
303
+ object: objectName,
304
+ mode,
305
+ inserted,
306
+ updated,
307
+ skipped,
308
+ errored,
309
+ total: dataset.records.length,
310
+ referencesResolved,
311
+ referencesDeferred,
312
+ errors,
313
+ };
314
+ }
315
+
316
+ // ==========================================================================
317
+ // Internal: Reference Resolution
318
+ // ==========================================================================
319
+
320
+ private async resolveFromDatabase(
321
+ targetObject: string,
322
+ targetField: string,
323
+ value: unknown,
324
+ ): Promise<string | null> {
325
+ try {
326
+ const records = await this.engine.find(targetObject, {
327
+ filter: { [targetField]: value },
328
+ select: ['id'],
329
+ limit: 1,
330
+ });
331
+ if (records && records.length > 0) {
332
+ return String(records[0].id || records[0]._id);
333
+ }
334
+ } catch {
335
+ // Target object may not exist yet
336
+ }
337
+ return null;
338
+ }
339
+
340
+ private async resolveDeferredUpdates(
341
+ deferredUpdates: DeferredUpdate[],
342
+ insertedRecords: Map<string, Map<string, string>>,
343
+ allResults: DatasetLoadResult[],
344
+ allErrors: ReferenceResolutionError[],
345
+ ): Promise<void> {
346
+ for (const deferred of deferredUpdates) {
347
+ // Try to resolve from inserted records
348
+ const targetMap = insertedRecords.get(deferred.targetObject);
349
+ let resolvedId = targetMap?.get(String(deferred.attemptedValue));
350
+
351
+ // Try database fallback
352
+ if (!resolvedId) {
353
+ resolvedId = (await this.resolveFromDatabase(
354
+ deferred.targetObject, deferred.targetField, deferred.attemptedValue
355
+ )) ?? undefined;
356
+ }
357
+
358
+ if (resolvedId) {
359
+ // Find the record and update the reference
360
+ const objectRecordMap = insertedRecords.get(deferred.objectName);
361
+ const recordId = objectRecordMap?.get(deferred.recordExternalId);
362
+
363
+ if (recordId) {
364
+ try {
365
+ await this.engine.update(deferred.objectName, {
366
+ id: recordId,
367
+ [deferred.field]: resolvedId,
368
+ });
369
+
370
+ // Update result stats
371
+ const resultEntry = allResults.find(r => r.object === deferred.objectName);
372
+ if (resultEntry) {
373
+ resultEntry.referencesResolved++;
374
+ resultEntry.referencesDeferred--;
375
+ }
376
+ } catch (err: any) {
377
+ this.logger.warn('[SeedLoader] Failed to resolve deferred reference', {
378
+ object: deferred.objectName,
379
+ field: deferred.field,
380
+ error: err.message,
381
+ });
382
+ }
383
+ }
384
+ } else {
385
+ // Still unresolved after pass 2
386
+ const error: ReferenceResolutionError = {
387
+ sourceObject: deferred.objectName,
388
+ field: deferred.field,
389
+ targetObject: deferred.targetObject,
390
+ targetField: deferred.targetField,
391
+ attemptedValue: deferred.attemptedValue,
392
+ recordIndex: deferred.recordIndex,
393
+ message: `Deferred reference unresolved after pass 2: ${deferred.objectName}.${deferred.field} = '${deferred.attemptedValue}' → ${deferred.targetObject}.${deferred.targetField} not found`,
394
+ };
395
+
396
+ const resultEntry = allResults.find(r => r.object === deferred.objectName);
397
+ if (resultEntry) {
398
+ resultEntry.errors.push(error);
399
+ }
400
+ allErrors.push(error);
401
+ }
402
+ }
403
+ }
404
+
405
+ // ==========================================================================
406
+ // Internal: Write Operations
407
+ // ==========================================================================
408
+
409
+ private async writeRecord(
410
+ objectName: string,
411
+ record: Record<string, unknown>,
412
+ mode: string,
413
+ externalId: string,
414
+ existingRecords?: Map<string, any>,
415
+ ): Promise<{ action: 'inserted' | 'updated' | 'skipped'; id?: string }> {
416
+ const externalIdValue = record[externalId];
417
+ const existing = existingRecords?.get(String(externalIdValue ?? ''));
418
+
419
+ switch (mode) {
420
+ case 'insert': {
421
+ const result = await this.engine.insert(objectName, record);
422
+ return { action: 'inserted', id: this.extractId(result) };
423
+ }
424
+
425
+ case 'update': {
426
+ if (!existing) {
427
+ return { action: 'skipped' };
428
+ }
429
+ const id = this.extractId(existing);
430
+ await this.engine.update(objectName, { ...record, id });
431
+ return { action: 'updated', id };
432
+ }
433
+
434
+ case 'upsert': {
435
+ if (existing) {
436
+ const id = this.extractId(existing);
437
+ await this.engine.update(objectName, { ...record, id });
438
+ return { action: 'updated', id };
439
+ } else {
440
+ const result = await this.engine.insert(objectName, record);
441
+ return { action: 'inserted', id: this.extractId(result) };
442
+ }
443
+ }
444
+
445
+ case 'ignore': {
446
+ if (existing) {
447
+ return { action: 'skipped', id: this.extractId(existing) };
448
+ }
449
+ const result = await this.engine.insert(objectName, record);
450
+ return { action: 'inserted', id: this.extractId(result) };
451
+ }
452
+
453
+ case 'replace': {
454
+ // Replace mode: just insert (caller should have cleared the table)
455
+ const result = await this.engine.insert(objectName, record);
456
+ return { action: 'inserted', id: this.extractId(result) };
457
+ }
458
+
459
+ default: {
460
+ const result = await this.engine.insert(objectName, record);
461
+ return { action: 'inserted', id: this.extractId(result) };
462
+ }
463
+ }
464
+ }
465
+
466
+ // ==========================================================================
467
+ // Internal: Dependency Graph
468
+ // ==========================================================================
469
+
470
+ /**
471
+ * Kahn's algorithm for topological sort with cycle detection.
472
+ */
473
+ private topologicalSort(
474
+ nodes: ObjectDependencyNode[],
475
+ ): { insertOrder: string[]; circularDependencies: string[][] } {
476
+ const inDegree = new Map<string, number>();
477
+ const adjacency = new Map<string, string[]>();
478
+ const objectSet = new Set(nodes.map(n => n.object));
479
+
480
+ // Initialize
481
+ for (const node of nodes) {
482
+ inDegree.set(node.object, 0);
483
+ adjacency.set(node.object, []);
484
+ }
485
+
486
+ // Build adjacency list and in-degree counts
487
+ for (const node of nodes) {
488
+ for (const dep of node.dependsOn) {
489
+ // Exclude self-references from ordering (e.g., employee.manager_id → employee).
490
+ // Self-referencing fields are still tracked in node.references for resolution.
491
+ if (objectSet.has(dep) && dep !== node.object) {
492
+ adjacency.get(dep)!.push(node.object);
493
+ inDegree.set(node.object, (inDegree.get(node.object) || 0) + 1);
494
+ }
495
+ }
496
+ }
497
+
498
+ // Kahn's algorithm
499
+ const queue: string[] = [];
500
+ for (const [obj, degree] of inDegree) {
501
+ if (degree === 0) queue.push(obj);
502
+ }
503
+
504
+ const insertOrder: string[] = [];
505
+ while (queue.length > 0) {
506
+ const current = queue.shift()!;
507
+ insertOrder.push(current);
508
+
509
+ for (const neighbor of (adjacency.get(current) || [])) {
510
+ const newDegree = (inDegree.get(neighbor) || 0) - 1;
511
+ inDegree.set(neighbor, newDegree);
512
+ if (newDegree === 0) {
513
+ queue.push(neighbor);
514
+ }
515
+ }
516
+ }
517
+
518
+ // Detect circular dependencies
519
+ const circularDependencies: string[][] = [];
520
+ const remaining = nodes.filter(n => !insertOrder.includes(n.object));
521
+
522
+ if (remaining.length > 0) {
523
+ // Find cycles using DFS
524
+ const cycles = this.findCycles(remaining);
525
+ circularDependencies.push(...cycles);
526
+
527
+ // Add remaining objects to insertOrder (they'll need multi-pass)
528
+ for (const node of remaining) {
529
+ if (!insertOrder.includes(node.object)) {
530
+ insertOrder.push(node.object);
531
+ }
532
+ }
533
+ }
534
+
535
+ return { insertOrder, circularDependencies };
536
+ }
537
+
538
+ private findCycles(nodes: ObjectDependencyNode[]): string[][] {
539
+ const cycles: string[][] = [];
540
+ const nodeMap = new Map(nodes.map(n => [n.object, n]));
541
+ const visited = new Set<string>();
542
+ const inStack = new Set<string>();
543
+
544
+ const dfs = (current: string, path: string[]) => {
545
+ if (inStack.has(current)) {
546
+ // Found a cycle
547
+ const cycleStart = path.indexOf(current);
548
+ if (cycleStart !== -1) {
549
+ cycles.push([...path.slice(cycleStart), current]);
550
+ }
551
+ return;
552
+ }
553
+ if (visited.has(current)) return;
554
+
555
+ visited.add(current);
556
+ inStack.add(current);
557
+ path.push(current);
558
+
559
+ const node = nodeMap.get(current);
560
+ if (node) {
561
+ for (const dep of node.dependsOn) {
562
+ if (nodeMap.has(dep)) {
563
+ dfs(dep, [...path]);
564
+ }
565
+ }
566
+ }
567
+
568
+ inStack.delete(current);
569
+ };
570
+
571
+ for (const node of nodes) {
572
+ if (!visited.has(node.object)) {
573
+ dfs(node.object, []);
574
+ }
575
+ }
576
+
577
+ return cycles;
578
+ }
579
+
580
+ // ==========================================================================
581
+ // Internal: Helpers
582
+ // ==========================================================================
583
+
584
+ private filterByEnv(datasets: Dataset[], env?: string): Dataset[] {
585
+ if (!env) return datasets;
586
+ return datasets.filter(d => (d.env as string[]).includes(env));
587
+ }
588
+
589
+ private orderDatasets(datasets: Dataset[], insertOrder: string[]): Dataset[] {
590
+ const orderMap = new Map(insertOrder.map((name, i) => [name, i]));
591
+ return [...datasets].sort((a, b) => {
592
+ const orderA = orderMap.get(a.object) ?? Number.MAX_SAFE_INTEGER;
593
+ const orderB = orderMap.get(b.object) ?? Number.MAX_SAFE_INTEGER;
594
+ return orderA - orderB;
595
+ });
596
+ }
597
+
598
+ private buildReferenceMap(graph: ObjectDependencyGraph): Map<string, ReferenceResolution[]> {
599
+ const map = new Map<string, ReferenceResolution[]>();
600
+ for (const node of graph.nodes) {
601
+ if (node.references.length > 0) {
602
+ map.set(node.object, node.references);
603
+ }
604
+ }
605
+ return map;
606
+ }
607
+
608
+ private async loadExistingRecords(
609
+ objectName: string,
610
+ externalId: string,
611
+ ): Promise<Map<string, any>> {
612
+ const map = new Map<string, any>();
613
+ try {
614
+ const records = await this.engine.find(objectName, {
615
+ select: ['id', externalId],
616
+ });
617
+ for (const record of records || []) {
618
+ const key = String(record[externalId] ?? '');
619
+ if (key) {
620
+ map.set(key, record);
621
+ }
622
+ }
623
+ } catch {
624
+ // Object may not have records yet
625
+ }
626
+ return map;
627
+ }
628
+
629
+ private looksLikeInternalId(value: string): boolean {
630
+ // UUID v4 pattern
631
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) {
632
+ return true;
633
+ }
634
+ // MongoDB ObjectId pattern (24 hex chars)
635
+ if (/^[0-9a-f]{24}$/i.test(value)) {
636
+ return true;
637
+ }
638
+ return false;
639
+ }
640
+
641
+ private extractId(record: any): string | undefined {
642
+ if (!record) return undefined;
643
+ return String(record.id || record._id || '');
644
+ }
645
+
646
+ private buildEmptyResult(config: SeedLoaderConfig, durationMs: number): SeedLoaderResult {
647
+ return {
648
+ success: true,
649
+ dryRun: config.dryRun,
650
+ dependencyGraph: { nodes: [], insertOrder: [], circularDependencies: [] },
651
+ results: [],
652
+ errors: [],
653
+ summary: {
654
+ objectsProcessed: 0,
655
+ totalRecords: 0,
656
+ totalInserted: 0,
657
+ totalUpdated: 0,
658
+ totalSkipped: 0,
659
+ totalErrored: 0,
660
+ totalReferencesResolved: 0,
661
+ totalReferencesDeferred: 0,
662
+ circularDependencyCount: 0,
663
+ durationMs,
664
+ },
665
+ };
666
+ }
667
+
668
+ private buildResult(
669
+ config: SeedLoaderConfig,
670
+ graph: ObjectDependencyGraph,
671
+ results: DatasetLoadResult[],
672
+ errors: ReferenceResolutionError[],
673
+ durationMs: number,
674
+ ): SeedLoaderResult {
675
+ const summary = {
676
+ objectsProcessed: results.length,
677
+ totalRecords: results.reduce((sum, r) => sum + r.total, 0),
678
+ totalInserted: results.reduce((sum, r) => sum + r.inserted, 0),
679
+ totalUpdated: results.reduce((sum, r) => sum + r.updated, 0),
680
+ totalSkipped: results.reduce((sum, r) => sum + r.skipped, 0),
681
+ totalErrored: results.reduce((sum, r) => sum + r.errored, 0),
682
+ totalReferencesResolved: results.reduce((sum, r) => sum + r.referencesResolved, 0),
683
+ totalReferencesDeferred: results.reduce((sum, r) => sum + r.referencesDeferred, 0),
684
+ circularDependencyCount: graph.circularDependencies.length,
685
+ durationMs,
686
+ };
687
+
688
+ const hasErrors = errors.length > 0 || summary.totalErrored > 0;
689
+
690
+ return {
691
+ success: !hasErrors,
692
+ dryRun: config.dryRun,
693
+ dependencyGraph: graph,
694
+ results,
695
+ errors,
696
+ summary,
697
+ };
698
+ }
699
+ }
700
+
701
+ // ==========================================================================
702
+ // Internal Types
703
+ // ==========================================================================
704
+
705
+ interface DeferredUpdate {
706
+ objectName: string;
707
+ recordExternalId: string;
708
+ field: string;
709
+ targetObject: string;
710
+ targetField: string;
711
+ attemptedValue: unknown;
712
+ recordIndex: number;
713
+ }