@objectstack/runtime 3.2.2 → 3.2.4

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,1123 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+ import { SeedLoaderService } from './seed-loader';
5
+ import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
6
+ import type { SeedLoaderRequest, SeedLoaderConfig } from '@objectstack/spec/data';
7
+
8
+ // ==========================================================================
9
+ // Mock Helpers
10
+ // ==========================================================================
11
+
12
+ function createMockLogger() {
13
+ return {
14
+ info: vi.fn(),
15
+ warn: vi.fn(),
16
+ error: vi.fn(),
17
+ debug: vi.fn(),
18
+ };
19
+ }
20
+
21
+ function createMockEngine(data: Record<string, any[]> = {}): IDataEngine {
22
+ const store: Record<string, any[]> = {};
23
+ for (const [key, records] of Object.entries(data)) {
24
+ store[key] = records.map((r, i) => ({ id: r.id || `id-${key}-${i}`, ...r }));
25
+ }
26
+ let idCounter = 0;
27
+
28
+ return {
29
+ find: vi.fn(async (objectName: string, query?: any) => {
30
+ const records = store[objectName] || [];
31
+ if (query?.filter) {
32
+ return records.filter(r => {
33
+ for (const [k, v] of Object.entries(query.filter)) {
34
+ if (r[k] !== v) return false;
35
+ }
36
+ return true;
37
+ });
38
+ }
39
+ return records;
40
+ }),
41
+ findOne: vi.fn(async (objectName: string, query?: any) => {
42
+ const results = await (store[objectName] || []);
43
+ return results[0] || null;
44
+ }),
45
+ insert: vi.fn(async (objectName: string, data: any) => {
46
+ if (!store[objectName]) store[objectName] = [];
47
+ const record = { id: `gen-${++idCounter}`, ...data };
48
+ store[objectName].push(record);
49
+ return record;
50
+ }),
51
+ update: vi.fn(async (objectName: string, data: any) => {
52
+ const records = store[objectName] || [];
53
+ const idx = records.findIndex(r => r.id === data.id);
54
+ if (idx >= 0) {
55
+ records[idx] = { ...records[idx], ...data };
56
+ return records[idx];
57
+ }
58
+ return data;
59
+ }),
60
+ delete: vi.fn(async () => ({ deleted: 1 })),
61
+ count: vi.fn(async (objectName: string) => (store[objectName] || []).length),
62
+ aggregate: vi.fn(async () => []),
63
+ };
64
+ }
65
+
66
+ function createMockMetadata(objects: Record<string, any> = {}): IMetadataService {
67
+ return {
68
+ getObject: vi.fn(async (name: string) => objects[name] || undefined),
69
+ listObjects: vi.fn(async () => Object.values(objects)),
70
+ register: vi.fn(async () => {}),
71
+ get: vi.fn(async (type: string, name: string) => objects[name]),
72
+ list: vi.fn(async () => []),
73
+ unregister: vi.fn(async () => {}),
74
+ exists: vi.fn(async () => false),
75
+ listNames: vi.fn(async () => []),
76
+ };
77
+ }
78
+
79
+ // ==========================================================================
80
+ // Tests
81
+ // ==========================================================================
82
+
83
+ describe('SeedLoaderService', () => {
84
+ let logger: ReturnType<typeof createMockLogger>;
85
+
86
+ beforeEach(() => {
87
+ logger = createMockLogger();
88
+ });
89
+
90
+ // ========================================================================
91
+ // buildDependencyGraph
92
+ // ========================================================================
93
+
94
+ describe('buildDependencyGraph', () => {
95
+ it('should build an empty graph for objects with no references', async () => {
96
+ const metadata = createMockMetadata({
97
+ account: { name: 'account', fields: { name: { type: 'text' } } },
98
+ product: { name: 'product', fields: { name: { type: 'text' } } },
99
+ });
100
+ const engine = createMockEngine();
101
+ const loader = new SeedLoaderService(engine, metadata, logger);
102
+
103
+ const graph = await loader.buildDependencyGraph(['account', 'product']);
104
+
105
+ expect(graph.nodes).toHaveLength(2);
106
+ expect(graph.insertOrder).toEqual(expect.arrayContaining(['account', 'product']));
107
+ expect(graph.circularDependencies).toEqual([]);
108
+ });
109
+
110
+ it('should detect lookup dependencies', async () => {
111
+ const metadata = createMockMetadata({
112
+ account: { name: 'account', fields: { name: { type: 'text' } } },
113
+ contact: {
114
+ name: 'contact',
115
+ fields: {
116
+ name: { type: 'text' },
117
+ account_id: { type: 'lookup', reference: 'account' },
118
+ },
119
+ },
120
+ });
121
+ const engine = createMockEngine();
122
+ const loader = new SeedLoaderService(engine, metadata, logger);
123
+
124
+ const graph = await loader.buildDependencyGraph(['account', 'contact']);
125
+
126
+ expect(graph.nodes.find(n => n.object === 'contact')?.dependsOn).toEqual(['account']);
127
+ // account should come before contact
128
+ const accountIdx = graph.insertOrder.indexOf('account');
129
+ const contactIdx = graph.insertOrder.indexOf('contact');
130
+ expect(accountIdx).toBeLessThan(contactIdx);
131
+ });
132
+
133
+ it('should detect master_detail dependencies', async () => {
134
+ const metadata = createMockMetadata({
135
+ project: { name: 'project', fields: { name: { type: 'text' } } },
136
+ task: {
137
+ name: 'task',
138
+ fields: {
139
+ name: { type: 'text' },
140
+ project_id: { type: 'master_detail', reference: 'project' },
141
+ },
142
+ },
143
+ });
144
+ const engine = createMockEngine();
145
+ const loader = new SeedLoaderService(engine, metadata, logger);
146
+
147
+ const graph = await loader.buildDependencyGraph(['project', 'task']);
148
+
149
+ const taskNode = graph.nodes.find(n => n.object === 'task');
150
+ expect(taskNode?.references[0].fieldType).toBe('master_detail');
151
+ expect(graph.insertOrder.indexOf('project')).toBeLessThan(graph.insertOrder.indexOf('task'));
152
+ });
153
+
154
+ it('should detect circular dependencies', async () => {
155
+ const metadata = createMockMetadata({
156
+ employee: {
157
+ name: 'employee',
158
+ fields: {
159
+ name: { type: 'text' },
160
+ manager_id: { type: 'lookup', reference: 'employee' },
161
+ },
162
+ },
163
+ });
164
+ const engine = createMockEngine();
165
+ const loader = new SeedLoaderService(engine, metadata, logger);
166
+
167
+ const graph = await loader.buildDependencyGraph(['employee']);
168
+
169
+ // Self-referencing should still be in insertOrder
170
+ expect(graph.insertOrder).toContain('employee');
171
+ });
172
+
173
+ it('should detect cross-object circular dependencies', async () => {
174
+ const metadata = createMockMetadata({
175
+ department: {
176
+ name: 'department',
177
+ fields: {
178
+ name: { type: 'text' },
179
+ head_id: { type: 'lookup', reference: 'employee' },
180
+ },
181
+ },
182
+ employee: {
183
+ name: 'employee',
184
+ fields: {
185
+ name: { type: 'text' },
186
+ department_id: { type: 'lookup', reference: 'department' },
187
+ },
188
+ },
189
+ });
190
+ const engine = createMockEngine();
191
+ const loader = new SeedLoaderService(engine, metadata, logger);
192
+
193
+ const graph = await loader.buildDependencyGraph(['department', 'employee']);
194
+
195
+ expect(graph.circularDependencies.length).toBeGreaterThan(0);
196
+ expect(graph.insertOrder).toContain('department');
197
+ expect(graph.insertOrder).toContain('employee');
198
+ });
199
+
200
+ it('should handle multi-level dependency chains', async () => {
201
+ const metadata = createMockMetadata({
202
+ org: { name: 'org', fields: { name: { type: 'text' } } },
203
+ department: {
204
+ name: 'department',
205
+ fields: {
206
+ name: { type: 'text' },
207
+ org_id: { type: 'lookup', reference: 'org' },
208
+ },
209
+ },
210
+ employee: {
211
+ name: 'employee',
212
+ fields: {
213
+ name: { type: 'text' },
214
+ department_id: { type: 'lookup', reference: 'department' },
215
+ },
216
+ },
217
+ });
218
+ const engine = createMockEngine();
219
+ const loader = new SeedLoaderService(engine, metadata, logger);
220
+
221
+ const graph = await loader.buildDependencyGraph(['org', 'department', 'employee']);
222
+
223
+ const orgIdx = graph.insertOrder.indexOf('org');
224
+ const deptIdx = graph.insertOrder.indexOf('department');
225
+ const empIdx = graph.insertOrder.indexOf('employee');
226
+ expect(orgIdx).toBeLessThan(deptIdx);
227
+ expect(deptIdx).toBeLessThan(empIdx);
228
+ });
229
+
230
+ it('should ignore references to objects not in the graph for dependency ordering', async () => {
231
+ const metadata = createMockMetadata({
232
+ contact: {
233
+ name: 'contact',
234
+ fields: {
235
+ name: { type: 'text' },
236
+ account_id: { type: 'lookup', reference: 'account' },
237
+ },
238
+ },
239
+ });
240
+ const engine = createMockEngine();
241
+ const loader = new SeedLoaderService(engine, metadata, logger);
242
+
243
+ // 'account' is not included in graph
244
+ const graph = await loader.buildDependencyGraph(['contact']);
245
+
246
+ // dependsOn should be empty (account not in graph)
247
+ expect(graph.nodes[0].dependsOn).toEqual([]);
248
+ // But references should still be tracked (for DB resolution)
249
+ expect(graph.nodes[0].references).toHaveLength(1);
250
+ expect(graph.nodes[0].references[0].targetObject).toBe('account');
251
+ expect(graph.insertOrder).toEqual(['contact']);
252
+ });
253
+
254
+ it('should handle objects with no metadata', async () => {
255
+ const metadata = createMockMetadata({});
256
+ const engine = createMockEngine();
257
+ const loader = new SeedLoaderService(engine, metadata, logger);
258
+
259
+ const graph = await loader.buildDependencyGraph(['unknown_object']);
260
+
261
+ expect(graph.nodes).toHaveLength(1);
262
+ expect(graph.nodes[0].dependsOn).toEqual([]);
263
+ expect(graph.insertOrder).toEqual(['unknown_object']);
264
+ });
265
+ });
266
+
267
+ // ========================================================================
268
+ // load — basic operations
269
+ // ========================================================================
270
+
271
+ describe('load — basic operations', () => {
272
+ it('should insert records for a single object with no references', async () => {
273
+ const metadata = createMockMetadata({
274
+ account: { name: 'account', fields: { name: { type: 'text' } } },
275
+ });
276
+ const engine = createMockEngine();
277
+ const loader = new SeedLoaderService(engine, metadata, logger);
278
+
279
+ const result = await loader.load({
280
+ datasets: [
281
+ { object: 'account', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Acme Corp' }, { name: 'Globex' }] },
282
+ ],
283
+ config: {
284
+ dryRun: false, haltOnError: false, multiPass: true,
285
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
286
+ },
287
+ });
288
+
289
+ expect(result.success).toBe(true);
290
+ expect(result.summary.totalInserted).toBe(2);
291
+ expect(result.summary.objectsProcessed).toBe(1);
292
+ expect(engine.insert).toHaveBeenCalledTimes(2);
293
+ });
294
+
295
+ it('should return empty result for no datasets', async () => {
296
+ const metadata = createMockMetadata({});
297
+ const engine = createMockEngine();
298
+ const loader = new SeedLoaderService(engine, metadata, logger);
299
+
300
+ const result = await loader.load({
301
+ datasets: [],
302
+ config: {
303
+ dryRun: false, haltOnError: false, multiPass: true,
304
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
305
+ },
306
+ });
307
+
308
+ expect(result.success).toBe(true);
309
+ expect(result.summary.totalRecords).toBe(0);
310
+ });
311
+
312
+ it('should handle environment filtering', async () => {
313
+ const metadata = createMockMetadata({
314
+ account: { name: 'account', fields: { name: { type: 'text' } } },
315
+ demo_data: { name: 'demo_data', fields: { name: { type: 'text' } } },
316
+ });
317
+ const engine = createMockEngine();
318
+ const loader = new SeedLoaderService(engine, metadata, logger);
319
+
320
+ const result = await loader.load({
321
+ datasets: [
322
+ { object: 'account', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Acme' }] },
323
+ { object: 'demo_data', externalId: 'name', mode: 'upsert', env: ['dev'], records: [{ name: 'Demo' }] },
324
+ ],
325
+ config: {
326
+ dryRun: false, haltOnError: false, multiPass: true,
327
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
328
+ env: 'prod',
329
+ },
330
+ });
331
+
332
+ // Only 'account' should be loaded (env: ['prod','dev','test'])
333
+ // 'demo_data' has env: ['dev'] which doesn't include 'prod'
334
+ expect(result.summary.objectsProcessed).toBe(1);
335
+ expect(result.summary.totalInserted).toBe(1);
336
+ });
337
+ });
338
+
339
+ // ========================================================================
340
+ // load — reference resolution
341
+ // ========================================================================
342
+
343
+ describe('load — reference resolution', () => {
344
+ it('should resolve lookup references via externalId', async () => {
345
+ const metadata = createMockMetadata({
346
+ account: { name: 'account', fields: { name: { type: 'text' } } },
347
+ contact: {
348
+ name: 'contact',
349
+ fields: {
350
+ name: { type: 'text' },
351
+ account_id: { type: 'lookup', reference: 'account' },
352
+ },
353
+ },
354
+ });
355
+ const engine = createMockEngine();
356
+ const loader = new SeedLoaderService(engine, metadata, logger);
357
+
358
+ const result = await loader.load({
359
+ datasets: [
360
+ { object: 'account', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Acme Corp' }] },
361
+ { object: 'contact', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'John', account_id: 'Acme Corp' }] },
362
+ ],
363
+ config: {
364
+ dryRun: false, haltOnError: false, multiPass: true,
365
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
366
+ },
367
+ });
368
+
369
+ expect(result.success).toBe(true);
370
+ expect(result.summary.totalReferencesResolved).toBe(1);
371
+
372
+ // The contact insert should have resolved account_id
373
+ const contactInsertCall = (engine.insert as any).mock.calls.find(
374
+ (c: any[]) => c[0] === 'contact'
375
+ );
376
+ expect(contactInsertCall).toBeDefined();
377
+ // account_id should be resolved to the generated ID, not 'Acme Corp'
378
+ expect(contactInsertCall[1].account_id).not.toBe('Acme Corp');
379
+ });
380
+
381
+ it('should skip reference resolution for null/undefined values', async () => {
382
+ const metadata = createMockMetadata({
383
+ account: { name: 'account', fields: { name: { type: 'text' } } },
384
+ contact: {
385
+ name: 'contact',
386
+ fields: {
387
+ name: { type: 'text' },
388
+ account_id: { type: 'lookup', reference: 'account' },
389
+ },
390
+ },
391
+ });
392
+ const engine = createMockEngine();
393
+ const loader = new SeedLoaderService(engine, metadata, logger);
394
+
395
+ const result = await loader.load({
396
+ datasets: [
397
+ { object: 'account', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Acme' }] },
398
+ { object: 'contact', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'John', account_id: null }] },
399
+ ],
400
+ config: {
401
+ dryRun: false, haltOnError: false, multiPass: true,
402
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
403
+ },
404
+ });
405
+
406
+ expect(result.success).toBe(true);
407
+ expect(result.summary.totalReferencesResolved).toBe(0);
408
+ });
409
+
410
+ it('should skip reference resolution for values that look like UUIDs', async () => {
411
+ const metadata = createMockMetadata({
412
+ account: { name: 'account', fields: { name: { type: 'text' } } },
413
+ contact: {
414
+ name: 'contact',
415
+ fields: {
416
+ name: { type: 'text' },
417
+ account_id: { type: 'lookup', reference: 'account' },
418
+ },
419
+ },
420
+ });
421
+ const engine = createMockEngine();
422
+ const loader = new SeedLoaderService(engine, metadata, logger);
423
+
424
+ const uuid = '550e8400-e29b-41d4-a716-446655440000';
425
+ const result = await loader.load({
426
+ datasets: [
427
+ { object: 'contact', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'John', account_id: uuid }] },
428
+ ],
429
+ config: {
430
+ dryRun: false, haltOnError: false, multiPass: true,
431
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
432
+ },
433
+ });
434
+
435
+ // UUID should be passed through without resolution
436
+ const insertCall = (engine.insert as any).mock.calls.find(
437
+ (c: any[]) => c[0] === 'contact'
438
+ );
439
+ expect(insertCall[1].account_id).toBe(uuid);
440
+ });
441
+
442
+ it('should resolve references from the database if not found in inserted records', async () => {
443
+ const metadata = createMockMetadata({
444
+ account: { name: 'account', fields: { name: { type: 'text' } } },
445
+ contact: {
446
+ name: 'contact',
447
+ fields: {
448
+ name: { type: 'text' },
449
+ account_id: { type: 'lookup', reference: 'account' },
450
+ },
451
+ },
452
+ });
453
+ // Pre-seed accounts in the mock engine
454
+ const engine = createMockEngine({
455
+ account: [{ id: 'existing-acme-id', name: 'Acme Corp' }],
456
+ });
457
+ const loader = new SeedLoaderService(engine, metadata, logger);
458
+
459
+ // Only load contacts (accounts already exist)
460
+ const result = await loader.load({
461
+ datasets: [
462
+ { object: 'contact', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'John', account_id: 'Acme Corp' }] },
463
+ ],
464
+ config: {
465
+ dryRun: false, haltOnError: false, multiPass: true,
466
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
467
+ },
468
+ });
469
+
470
+ expect(result.summary.totalReferencesResolved).toBe(1);
471
+ // The insert call should have the resolved ID
472
+ const insertCall = (engine.insert as any).mock.calls.find(
473
+ (c: any[]) => c[0] === 'contact'
474
+ );
475
+ expect(insertCall[1].account_id).toBe('existing-acme-id');
476
+ });
477
+
478
+ it('should report errors for unresolvable references when multiPass is false', async () => {
479
+ const metadata = createMockMetadata({
480
+ contact: {
481
+ name: 'contact',
482
+ fields: {
483
+ name: { type: 'text' },
484
+ account_id: { type: 'lookup', reference: 'account' },
485
+ },
486
+ },
487
+ account: { name: 'account', fields: { name: { type: 'text' } } },
488
+ });
489
+ const engine = createMockEngine();
490
+ const loader = new SeedLoaderService(engine, metadata, logger);
491
+
492
+ const result = await loader.load({
493
+ datasets: [
494
+ { object: 'contact', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'John', account_id: 'NonExistent' }] },
495
+ { object: 'account', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [] },
496
+ ],
497
+ config: {
498
+ dryRun: false, haltOnError: false, multiPass: false,
499
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
500
+ },
501
+ });
502
+
503
+ expect(result.success).toBe(false);
504
+ expect(result.errors.length).toBeGreaterThan(0);
505
+ expect(result.errors[0].sourceObject).toBe('contact');
506
+ expect(result.errors[0].field).toBe('account_id');
507
+ expect(result.errors[0].attemptedValue).toBe('NonExistent');
508
+ });
509
+ });
510
+
511
+ // ========================================================================
512
+ // load — multi-pass (circular dependencies)
513
+ // ========================================================================
514
+
515
+ describe('load — multi-pass loading', () => {
516
+ it('should defer references for circular dependencies and resolve in pass 2', async () => {
517
+ const metadata = createMockMetadata({
518
+ department: {
519
+ name: 'department',
520
+ fields: {
521
+ name: { type: 'text' },
522
+ head_id: { type: 'lookup', reference: 'employee' },
523
+ },
524
+ },
525
+ employee: {
526
+ name: 'employee',
527
+ fields: {
528
+ name: { type: 'text' },
529
+ department_id: { type: 'lookup', reference: 'department' },
530
+ },
531
+ },
532
+ });
533
+ const engine = createMockEngine();
534
+ const loader = new SeedLoaderService(engine, metadata, logger);
535
+
536
+ const result = await loader.load({
537
+ datasets: [
538
+ { object: 'department', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Engineering', head_id: 'Alice' }] },
539
+ { object: 'employee', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Alice', department_id: 'Engineering' }] },
540
+ ],
541
+ config: {
542
+ dryRun: false, haltOnError: false, multiPass: true,
543
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
544
+ },
545
+ });
546
+
547
+ // Both objects should be inserted
548
+ expect(result.summary.totalInserted).toBe(2);
549
+ // References should be deferred then resolved
550
+ expect(result.summary.totalReferencesResolved).toBeGreaterThanOrEqual(1);
551
+ });
552
+ });
553
+
554
+ // ========================================================================
555
+ // load — upsert mode
556
+ // ========================================================================
557
+
558
+ describe('load — upsert mode', () => {
559
+ it('should update existing records instead of inserting duplicates', async () => {
560
+ const metadata = createMockMetadata({
561
+ account: { name: 'account', fields: { name: { type: 'text' }, status: { type: 'text' } } },
562
+ });
563
+ const engine = createMockEngine({
564
+ account: [{ id: 'acc-1', name: 'Acme Corp', status: 'active' }],
565
+ });
566
+ const loader = new SeedLoaderService(engine, metadata, logger);
567
+
568
+ const result = await loader.load({
569
+ datasets: [
570
+ {
571
+ object: 'account', externalId: 'name', mode: 'upsert',
572
+ env: ['prod', 'dev', 'test'],
573
+ records: [{ name: 'Acme Corp', status: 'inactive' }],
574
+ },
575
+ ],
576
+ config: {
577
+ dryRun: false, haltOnError: false, multiPass: true,
578
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
579
+ },
580
+ });
581
+
582
+ expect(result.summary.totalUpdated).toBe(1);
583
+ expect(result.summary.totalInserted).toBe(0);
584
+ expect(engine.update).toHaveBeenCalled();
585
+ });
586
+
587
+ it('should insert new records in upsert mode', async () => {
588
+ const metadata = createMockMetadata({
589
+ account: { name: 'account', fields: { name: { type: 'text' } } },
590
+ });
591
+ const engine = createMockEngine({ account: [] });
592
+ const loader = new SeedLoaderService(engine, metadata, logger);
593
+
594
+ const result = await loader.load({
595
+ datasets: [
596
+ {
597
+ object: 'account', externalId: 'name', mode: 'upsert',
598
+ env: ['prod', 'dev', 'test'],
599
+ records: [{ name: 'New Corp' }],
600
+ },
601
+ ],
602
+ config: {
603
+ dryRun: false, haltOnError: false, multiPass: true,
604
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
605
+ },
606
+ });
607
+
608
+ expect(result.summary.totalInserted).toBe(1);
609
+ expect(result.summary.totalUpdated).toBe(0);
610
+ });
611
+
612
+ it('should skip existing records in ignore mode', async () => {
613
+ const metadata = createMockMetadata({
614
+ account: { name: 'account', fields: { name: { type: 'text' } } },
615
+ });
616
+ const engine = createMockEngine({
617
+ account: [{ id: 'acc-1', name: 'Acme Corp' }],
618
+ });
619
+ const loader = new SeedLoaderService(engine, metadata, logger);
620
+
621
+ const result = await loader.load({
622
+ datasets: [
623
+ {
624
+ object: 'account', externalId: 'name', mode: 'ignore',
625
+ env: ['prod', 'dev', 'test'],
626
+ records: [{ name: 'Acme Corp' }, { name: 'New Corp' }],
627
+ },
628
+ ],
629
+ config: {
630
+ dryRun: false, haltOnError: false, multiPass: true,
631
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
632
+ },
633
+ });
634
+
635
+ expect(result.summary.totalSkipped).toBe(1);
636
+ expect(result.summary.totalInserted).toBe(1);
637
+ });
638
+
639
+ it('should only update existing records in update mode', async () => {
640
+ const metadata = createMockMetadata({
641
+ account: { name: 'account', fields: { name: { type: 'text' }, status: { type: 'text' } } },
642
+ });
643
+ const engine = createMockEngine({
644
+ account: [{ id: 'acc-1', name: 'Acme Corp', status: 'active' }],
645
+ });
646
+ const loader = new SeedLoaderService(engine, metadata, logger);
647
+
648
+ const result = await loader.load({
649
+ datasets: [
650
+ {
651
+ object: 'account', externalId: 'name', mode: 'update',
652
+ env: ['prod', 'dev', 'test'],
653
+ records: [
654
+ { name: 'Acme Corp', status: 'inactive' },
655
+ { name: 'Unknown Corp', status: 'new' },
656
+ ],
657
+ },
658
+ ],
659
+ config: {
660
+ dryRun: false, haltOnError: false, multiPass: true,
661
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
662
+ },
663
+ });
664
+
665
+ expect(result.summary.totalUpdated).toBe(1);
666
+ expect(result.summary.totalSkipped).toBe(1);
667
+ expect(result.summary.totalInserted).toBe(0);
668
+ });
669
+ });
670
+
671
+ // ========================================================================
672
+ // load — dry-run mode
673
+ // ========================================================================
674
+
675
+ describe('load — dry-run mode', () => {
676
+ it('should not write any data in dry-run mode', async () => {
677
+ const metadata = createMockMetadata({
678
+ account: { name: 'account', fields: { name: { type: 'text' } } },
679
+ });
680
+ const engine = createMockEngine();
681
+ const loader = new SeedLoaderService(engine, metadata, logger);
682
+
683
+ const result = await loader.load({
684
+ datasets: [
685
+ { object: 'account', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Acme' }] },
686
+ ],
687
+ config: {
688
+ dryRun: true, haltOnError: false, multiPass: true,
689
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
690
+ },
691
+ });
692
+
693
+ expect(result.dryRun).toBe(true);
694
+ expect(engine.insert).not.toHaveBeenCalled();
695
+ expect(engine.update).not.toHaveBeenCalled();
696
+ });
697
+
698
+ it('should detect reference errors in dry-run mode', async () => {
699
+ const metadata = createMockMetadata({
700
+ account: { name: 'account', fields: { name: { type: 'text' } } },
701
+ contact: {
702
+ name: 'contact',
703
+ fields: {
704
+ name: { type: 'text' },
705
+ account_id: { type: 'lookup', reference: 'account' },
706
+ },
707
+ },
708
+ });
709
+ const engine = createMockEngine();
710
+ const loader = new SeedLoaderService(engine, metadata, logger);
711
+
712
+ const result = await loader.load({
713
+ datasets: [
714
+ { object: 'account', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Acme' }] },
715
+ { object: 'contact', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'John', account_id: 'NonExistent' }] },
716
+ ],
717
+ config: {
718
+ dryRun: true, haltOnError: false, multiPass: true,
719
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
720
+ },
721
+ });
722
+
723
+ // Should report unresolvable reference
724
+ expect(result.errors.length).toBeGreaterThan(0);
725
+ expect(result.errors[0].attemptedValue).toBe('NonExistent');
726
+ });
727
+
728
+ it('should succeed in dry-run when references resolve correctly', async () => {
729
+ const metadata = createMockMetadata({
730
+ account: { name: 'account', fields: { name: { type: 'text' } } },
731
+ contact: {
732
+ name: 'contact',
733
+ fields: {
734
+ name: { type: 'text' },
735
+ account_id: { type: 'lookup', reference: 'account' },
736
+ },
737
+ },
738
+ });
739
+ const engine = createMockEngine();
740
+ const loader = new SeedLoaderService(engine, metadata, logger);
741
+
742
+ const result = await loader.load({
743
+ datasets: [
744
+ { object: 'account', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Acme' }] },
745
+ { object: 'contact', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'John', account_id: 'Acme' }] },
746
+ ],
747
+ config: {
748
+ dryRun: true, haltOnError: false, multiPass: true,
749
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
750
+ },
751
+ });
752
+
753
+ expect(result.success).toBe(true);
754
+ expect(result.errors).toHaveLength(0);
755
+ });
756
+ });
757
+
758
+ // ========================================================================
759
+ // load — halt on error
760
+ // ========================================================================
761
+
762
+ describe('load — haltOnError', () => {
763
+ it('should stop processing on first error when haltOnError is true', async () => {
764
+ const metadata = createMockMetadata({
765
+ account: { name: 'account', fields: { name: { type: 'text' } } },
766
+ contact: { name: 'contact', fields: { name: { type: 'text' } } },
767
+ });
768
+ const engine = createMockEngine();
769
+ // Make insert throw for account
770
+ (engine.insert as any).mockImplementationOnce(async () => {
771
+ throw new Error('DB error');
772
+ });
773
+ const loader = new SeedLoaderService(engine, metadata, logger);
774
+
775
+ const result = await loader.load({
776
+ datasets: [
777
+ { object: 'account', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Acme' }] },
778
+ { object: 'contact', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'John' }] },
779
+ ],
780
+ config: {
781
+ dryRun: false, haltOnError: true, multiPass: true,
782
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
783
+ },
784
+ });
785
+
786
+ // Should process account (with error) then stop
787
+ expect(result.summary.totalErrored).toBe(1);
788
+ // Contact should not have been processed
789
+ expect(result.summary.objectsProcessed).toBe(1);
790
+ });
791
+ });
792
+
793
+ // ========================================================================
794
+ // load — dependency ordering
795
+ // ========================================================================
796
+
797
+ describe('load — dependency ordering', () => {
798
+ it('should insert parent objects before child objects regardless of dataset order', async () => {
799
+ const metadata = createMockMetadata({
800
+ account: { name: 'account', fields: { name: { type: 'text' } } },
801
+ contact: {
802
+ name: 'contact',
803
+ fields: {
804
+ name: { type: 'text' },
805
+ account_id: { type: 'lookup', reference: 'account' },
806
+ },
807
+ },
808
+ });
809
+ const insertOrder: string[] = [];
810
+ const engine = createMockEngine();
811
+ let idCounter = 0;
812
+ (engine.insert as any).mockImplementation(async (objectName: string, data: any) => {
813
+ insertOrder.push(objectName);
814
+ return { id: `gen-${++idCounter}`, ...data };
815
+ });
816
+ const loader = new SeedLoaderService(engine, metadata, logger);
817
+
818
+ // Deliberately put contact before account
819
+ await loader.load({
820
+ datasets: [
821
+ { object: 'contact', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'John', account_id: 'Acme' }] },
822
+ { object: 'account', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Acme' }] },
823
+ ],
824
+ config: {
825
+ dryRun: false, haltOnError: false, multiPass: true,
826
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
827
+ },
828
+ });
829
+
830
+ // Account should be inserted before contact
831
+ expect(insertOrder.indexOf('account')).toBeLessThan(insertOrder.indexOf('contact'));
832
+ });
833
+ });
834
+
835
+ // ========================================================================
836
+ // load — error reporting
837
+ // ========================================================================
838
+
839
+ describe('load — error reporting', () => {
840
+ it('should produce actionable error messages', async () => {
841
+ const metadata = createMockMetadata({
842
+ account: { name: 'account', fields: { name: { type: 'text' } } },
843
+ contact: {
844
+ name: 'contact',
845
+ fields: {
846
+ name: { type: 'text' },
847
+ account_id: { type: 'lookup', reference: 'account' },
848
+ },
849
+ },
850
+ });
851
+ const engine = createMockEngine();
852
+ const loader = new SeedLoaderService(engine, metadata, logger);
853
+
854
+ const result = await loader.load({
855
+ datasets: [
856
+ { object: 'account', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [] },
857
+ { object: 'contact', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'John', account_id: 'MissingAccount' }] },
858
+ ],
859
+ config: {
860
+ dryRun: false, haltOnError: false, multiPass: false,
861
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
862
+ },
863
+ });
864
+
865
+ expect(result.errors).toHaveLength(1);
866
+ const error = result.errors[0];
867
+ expect(error.sourceObject).toBe('contact');
868
+ expect(error.field).toBe('account_id');
869
+ expect(error.targetObject).toBe('account');
870
+ expect(error.targetField).toBe('name');
871
+ expect(error.attemptedValue).toBe('MissingAccount');
872
+ expect(error.recordIndex).toBe(0);
873
+ expect(error.message).toContain('Cannot resolve reference');
874
+ expect(error.message).toContain('contact.account_id');
875
+ expect(error.message).toContain('MissingAccount');
876
+ });
877
+
878
+ it('should include per-object error details in results', async () => {
879
+ const metadata = createMockMetadata({
880
+ account: { name: 'account', fields: { name: { type: 'text' } } },
881
+ contact: {
882
+ name: 'contact',
883
+ fields: {
884
+ name: { type: 'text' },
885
+ account_id: { type: 'lookup', reference: 'account' },
886
+ },
887
+ },
888
+ });
889
+ const engine = createMockEngine();
890
+ const loader = new SeedLoaderService(engine, metadata, logger);
891
+
892
+ const result = await loader.load({
893
+ datasets: [
894
+ { object: 'account', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [] },
895
+ { object: 'contact', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [
896
+ { name: 'John', account_id: 'Missing1' },
897
+ { name: 'Jane', account_id: 'Missing2' },
898
+ ]},
899
+ ],
900
+ config: {
901
+ dryRun: false, haltOnError: false, multiPass: false,
902
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
903
+ },
904
+ });
905
+
906
+ const contactResult = result.results.find(r => r.object === 'contact');
907
+ expect(contactResult?.errors).toHaveLength(2);
908
+ expect(contactResult?.errors[0].recordIndex).toBe(0);
909
+ expect(contactResult?.errors[1].recordIndex).toBe(1);
910
+ });
911
+ });
912
+
913
+ // ========================================================================
914
+ // validate
915
+ // ========================================================================
916
+
917
+ describe('validate', () => {
918
+ it('should run in dry-run mode', async () => {
919
+ const metadata = createMockMetadata({
920
+ account: { name: 'account', fields: { name: { type: 'text' } } },
921
+ });
922
+ const engine = createMockEngine();
923
+ const loader = new SeedLoaderService(engine, metadata, logger);
924
+
925
+ const result = await loader.validate([
926
+ { object: 'account', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Acme' }] },
927
+ ]);
928
+
929
+ expect(result.dryRun).toBe(true);
930
+ expect(engine.insert).not.toHaveBeenCalled();
931
+ });
932
+ });
933
+
934
+ // ========================================================================
935
+ // load — result structure
936
+ // ========================================================================
937
+
938
+ describe('load — result structure', () => {
939
+ it('should include dependency graph in result', async () => {
940
+ const metadata = createMockMetadata({
941
+ account: { name: 'account', fields: { name: { type: 'text' } } },
942
+ });
943
+ const engine = createMockEngine();
944
+ const loader = new SeedLoaderService(engine, metadata, logger);
945
+
946
+ const result = await loader.load({
947
+ datasets: [
948
+ { object: 'account', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Acme' }] },
949
+ ],
950
+ config: {
951
+ dryRun: false, haltOnError: false, multiPass: true,
952
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
953
+ },
954
+ });
955
+
956
+ expect(result.dependencyGraph).toBeDefined();
957
+ expect(result.dependencyGraph.nodes).toHaveLength(1);
958
+ expect(result.dependencyGraph.insertOrder).toEqual(['account']);
959
+ });
960
+
961
+ it('should include complete summary statistics', async () => {
962
+ const metadata = createMockMetadata({
963
+ account: { name: 'account', fields: { name: { type: 'text' } } },
964
+ });
965
+ const engine = createMockEngine();
966
+ const loader = new SeedLoaderService(engine, metadata, logger);
967
+
968
+ const result = await loader.load({
969
+ datasets: [
970
+ { object: 'account', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'A' }, { name: 'B' }] },
971
+ ],
972
+ config: {
973
+ dryRun: false, haltOnError: false, multiPass: true,
974
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
975
+ },
976
+ });
977
+
978
+ expect(result.summary).toMatchObject({
979
+ objectsProcessed: 1,
980
+ totalRecords: 2,
981
+ totalInserted: 2,
982
+ totalUpdated: 0,
983
+ totalSkipped: 0,
984
+ totalErrored: 0,
985
+ totalReferencesResolved: 0,
986
+ totalReferencesDeferred: 0,
987
+ circularDependencyCount: 0,
988
+ });
989
+ expect(result.summary.durationMs).toBeGreaterThanOrEqual(0);
990
+ });
991
+
992
+ it('should track durationMs', async () => {
993
+ const metadata = createMockMetadata({
994
+ account: { name: 'account', fields: { name: { type: 'text' } } },
995
+ });
996
+ const engine = createMockEngine();
997
+ const loader = new SeedLoaderService(engine, metadata, logger);
998
+
999
+ const result = await loader.load({
1000
+ datasets: [
1001
+ { object: 'account', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Acme' }] },
1002
+ ],
1003
+ config: {
1004
+ dryRun: false, haltOnError: false, multiPass: true,
1005
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
1006
+ },
1007
+ });
1008
+
1009
+ expect(typeof result.summary.durationMs).toBe('number');
1010
+ expect(result.summary.durationMs).toBeGreaterThanOrEqual(0);
1011
+ });
1012
+ });
1013
+
1014
+ // ========================================================================
1015
+ // load — edge cases
1016
+ // ========================================================================
1017
+
1018
+ describe('load — edge cases', () => {
1019
+ it('should handle records with no matching externalId field', async () => {
1020
+ const metadata = createMockMetadata({
1021
+ account: { name: 'account', fields: { name: { type: 'text' } } },
1022
+ });
1023
+ const engine = createMockEngine();
1024
+ const loader = new SeedLoaderService(engine, metadata, logger);
1025
+
1026
+ const result = await loader.load({
1027
+ datasets: [
1028
+ { object: 'account', externalId: 'code', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Acme' }] },
1029
+ ],
1030
+ config: {
1031
+ dryRun: false, haltOnError: false, multiPass: true,
1032
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
1033
+ },
1034
+ });
1035
+
1036
+ // Should still insert (externalId 'code' not present in record, insert path)
1037
+ expect(result.summary.totalInserted).toBe(1);
1038
+ });
1039
+
1040
+ it('should handle insert errors gracefully', async () => {
1041
+ const metadata = createMockMetadata({
1042
+ account: { name: 'account', fields: { name: { type: 'text' } } },
1043
+ });
1044
+ const engine = createMockEngine();
1045
+ (engine.insert as any).mockRejectedValue(new Error('Duplicate key'));
1046
+ const loader = new SeedLoaderService(engine, metadata, logger);
1047
+
1048
+ const result = await loader.load({
1049
+ datasets: [
1050
+ { object: 'account', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Acme' }] },
1051
+ ],
1052
+ config: {
1053
+ dryRun: false, haltOnError: false, multiPass: true,
1054
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
1055
+ },
1056
+ });
1057
+
1058
+ expect(result.summary.totalErrored).toBe(1);
1059
+ expect(logger.warn).toHaveBeenCalled();
1060
+ });
1061
+
1062
+ it('should handle multiple references on same object', async () => {
1063
+ const metadata = createMockMetadata({
1064
+ account: { name: 'account', fields: { name: { type: 'text' } } },
1065
+ user: { name: 'user', fields: { name: { type: 'text' } } },
1066
+ opportunity: {
1067
+ name: 'opportunity',
1068
+ fields: {
1069
+ name: { type: 'text' },
1070
+ account_id: { type: 'lookup', reference: 'account' },
1071
+ owner_id: { type: 'lookup', reference: 'user' },
1072
+ },
1073
+ },
1074
+ });
1075
+ const engine = createMockEngine();
1076
+ const loader = new SeedLoaderService(engine, metadata, logger);
1077
+
1078
+ const result = await loader.load({
1079
+ datasets: [
1080
+ { object: 'account', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Acme' }] },
1081
+ { object: 'user', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Admin' }] },
1082
+ { object: 'opportunity', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Deal', account_id: 'Acme', owner_id: 'Admin' }] },
1083
+ ],
1084
+ config: {
1085
+ dryRun: false, haltOnError: false, multiPass: true,
1086
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
1087
+ },
1088
+ });
1089
+
1090
+ expect(result.success).toBe(true);
1091
+ expect(result.summary.totalReferencesResolved).toBe(2);
1092
+ });
1093
+
1094
+ it('should skip reference resolution for MongoDB ObjectId-like values', async () => {
1095
+ const metadata = createMockMetadata({
1096
+ account: { name: 'account', fields: { name: { type: 'text' } } },
1097
+ contact: {
1098
+ name: 'contact',
1099
+ fields: {
1100
+ name: { type: 'text' },
1101
+ account_id: { type: 'lookup', reference: 'account' },
1102
+ },
1103
+ },
1104
+ });
1105
+ const engine = createMockEngine();
1106
+ const loader = new SeedLoaderService(engine, metadata, logger);
1107
+
1108
+ const objectId = '507f1f77bcf86cd799439011';
1109
+ const result = await loader.load({
1110
+ datasets: [
1111
+ { object: 'contact', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'John', account_id: objectId }] },
1112
+ ],
1113
+ config: {
1114
+ dryRun: false, haltOnError: false, multiPass: true,
1115
+ defaultMode: 'upsert', batchSize: 1000, transaction: false,
1116
+ },
1117
+ });
1118
+
1119
+ const insertCall = (engine.insert as any).mock.calls[0];
1120
+ expect(insertCall[1].account_id).toBe(objectId);
1121
+ });
1122
+ });
1123
+ });