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