@objectstack/runtime 4.0.4 → 4.1.0
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.
- package/README.md +62 -0
- package/dist/index.cjs +40646 -2294
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1588 -6
- package/dist/index.d.ts +1588 -6
- package/dist/index.js +40671 -2328
- package/dist/index.js.map +1 -1
- package/package.json +45 -9
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -763
- package/src/app-plugin.test.ts +0 -274
- package/src/app-plugin.ts +0 -285
- package/src/dispatcher-plugin.ts +0 -503
- package/src/driver-plugin.ts +0 -76
- package/src/http-dispatcher.root.test.ts +0 -73
- package/src/http-dispatcher.test.ts +0 -1317
- package/src/http-dispatcher.ts +0 -1483
- package/src/http-server.ts +0 -142
- package/src/index.ts +0 -39
- package/src/middleware.ts +0 -222
- package/src/runtime.test.ts +0 -65
- package/src/runtime.ts +0 -69
- package/src/seed-loader.test.ts +0 -1123
- package/src/seed-loader.ts +0 -713
- package/tsconfig.json +0 -10
- package/vitest.config.ts +0 -26
package/src/seed-loader.test.ts
DELETED
|
@@ -1,1123 +0,0 @@
|
|
|
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
|
-
});
|