@objectstack/runtime 3.2.2 → 3.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -75,6 +75,518 @@ var DriverPlugin = class {
75
75
  }
76
76
  };
77
77
 
78
+ // src/seed-loader.ts
79
+ var DEFAULT_EXTERNAL_ID_FIELD = "name";
80
+ var SeedLoaderService = class {
81
+ constructor(engine, metadata, logger) {
82
+ this.engine = engine;
83
+ this.metadata = metadata;
84
+ this.logger = logger;
85
+ }
86
+ // ==========================================================================
87
+ // Public API
88
+ // ==========================================================================
89
+ async load(request) {
90
+ const startTime = Date.now();
91
+ const config = request.config;
92
+ const allErrors = [];
93
+ const allResults = [];
94
+ const datasets = this.filterByEnv(request.datasets, config.env);
95
+ if (datasets.length === 0) {
96
+ return this.buildEmptyResult(config, Date.now() - startTime);
97
+ }
98
+ const objectNames = datasets.map((d) => d.object);
99
+ const graph = await this.buildDependencyGraph(objectNames);
100
+ this.logger.info("[SeedLoader] Dependency graph built", {
101
+ objects: objectNames.length,
102
+ insertOrder: graph.insertOrder,
103
+ circularDeps: graph.circularDependencies.length
104
+ });
105
+ const orderedDatasets = this.orderDatasets(datasets, graph.insertOrder);
106
+ const refMap = this.buildReferenceMap(graph);
107
+ const insertedRecords = /* @__PURE__ */ new Map();
108
+ const deferredUpdates = [];
109
+ for (const dataset of orderedDatasets) {
110
+ const result = await this.loadDataset(
111
+ dataset,
112
+ config,
113
+ refMap,
114
+ insertedRecords,
115
+ deferredUpdates,
116
+ allErrors
117
+ );
118
+ allResults.push(result);
119
+ if (config.haltOnError && result.errored > 0) {
120
+ this.logger.warn("[SeedLoader] Halting on first error", { object: dataset.object });
121
+ break;
122
+ }
123
+ }
124
+ if (config.multiPass && deferredUpdates.length > 0 && !config.dryRun) {
125
+ this.logger.info("[SeedLoader] Pass 2: resolving deferred references", {
126
+ count: deferredUpdates.length
127
+ });
128
+ await this.resolveDeferredUpdates(deferredUpdates, insertedRecords, allResults, allErrors);
129
+ }
130
+ const durationMs = Date.now() - startTime;
131
+ return this.buildResult(config, graph, allResults, allErrors, durationMs);
132
+ }
133
+ async buildDependencyGraph(objectNames) {
134
+ const nodes = [];
135
+ const objectSet = new Set(objectNames);
136
+ for (const objectName of objectNames) {
137
+ const objDef = await this.metadata.getObject(objectName);
138
+ const dependsOn = [];
139
+ const references = [];
140
+ if (objDef && objDef.fields) {
141
+ const fields = objDef.fields;
142
+ for (const [fieldName, fieldDef] of Object.entries(fields)) {
143
+ if ((fieldDef.type === "lookup" || fieldDef.type === "master_detail") && fieldDef.reference) {
144
+ const targetObject = fieldDef.reference;
145
+ if (objectSet.has(targetObject) && !dependsOn.includes(targetObject)) {
146
+ dependsOn.push(targetObject);
147
+ }
148
+ references.push({
149
+ field: fieldName,
150
+ targetObject,
151
+ targetField: DEFAULT_EXTERNAL_ID_FIELD,
152
+ fieldType: fieldDef.type
153
+ });
154
+ }
155
+ }
156
+ }
157
+ nodes.push({ object: objectName, dependsOn, references });
158
+ }
159
+ const { insertOrder, circularDependencies } = this.topologicalSort(nodes);
160
+ return { nodes, insertOrder, circularDependencies };
161
+ }
162
+ async validate(datasets, config) {
163
+ const { SeedLoaderConfigSchema } = await import("@objectstack/spec/data");
164
+ const parsedConfig = SeedLoaderConfigSchema.parse({ ...config, dryRun: true });
165
+ return this.load({ datasets, config: parsedConfig });
166
+ }
167
+ // ==========================================================================
168
+ // Internal: Dataset Loading
169
+ // ==========================================================================
170
+ async loadDataset(dataset, config, refMap, insertedRecords, deferredUpdates, allErrors) {
171
+ const objectName = dataset.object;
172
+ const mode = dataset.mode || config.defaultMode;
173
+ const externalId = dataset.externalId || "name";
174
+ let inserted = 0;
175
+ let updated = 0;
176
+ let skipped = 0;
177
+ let errored = 0;
178
+ let referencesResolved = 0;
179
+ let referencesDeferred = 0;
180
+ const errors = [];
181
+ if (!insertedRecords.has(objectName)) {
182
+ insertedRecords.set(objectName, /* @__PURE__ */ new Map());
183
+ }
184
+ let existingRecords;
185
+ if ((mode === "upsert" || mode === "update" || mode === "ignore") && !config.dryRun) {
186
+ existingRecords = await this.loadExistingRecords(objectName, externalId);
187
+ }
188
+ const objectRefs = refMap.get(objectName) || [];
189
+ for (let i = 0; i < dataset.records.length; i++) {
190
+ const record = { ...dataset.records[i] };
191
+ for (const ref of objectRefs) {
192
+ const fieldValue = record[ref.field];
193
+ if (fieldValue === void 0 || fieldValue === null) continue;
194
+ if (typeof fieldValue !== "string" || this.looksLikeInternalId(fieldValue)) continue;
195
+ const targetMap = insertedRecords.get(ref.targetObject);
196
+ const resolvedId = targetMap?.get(String(fieldValue));
197
+ if (resolvedId) {
198
+ record[ref.field] = resolvedId;
199
+ referencesResolved++;
200
+ } else if (!config.dryRun) {
201
+ const dbId = await this.resolveFromDatabase(ref.targetObject, ref.targetField, fieldValue);
202
+ if (dbId) {
203
+ record[ref.field] = dbId;
204
+ referencesResolved++;
205
+ } else if (config.multiPass) {
206
+ record[ref.field] = null;
207
+ deferredUpdates.push({
208
+ objectName,
209
+ recordExternalId: String(record[externalId] ?? ""),
210
+ field: ref.field,
211
+ targetObject: ref.targetObject,
212
+ targetField: ref.targetField,
213
+ attemptedValue: fieldValue,
214
+ recordIndex: i
215
+ });
216
+ referencesDeferred++;
217
+ } else {
218
+ const error = {
219
+ sourceObject: objectName,
220
+ field: ref.field,
221
+ targetObject: ref.targetObject,
222
+ targetField: ref.targetField,
223
+ attemptedValue: fieldValue,
224
+ recordIndex: i,
225
+ message: `Cannot resolve reference: ${objectName}.${ref.field} = '${fieldValue}' \u2192 ${ref.targetObject}.${ref.targetField} not found`
226
+ };
227
+ errors.push(error);
228
+ allErrors.push(error);
229
+ }
230
+ } else {
231
+ const targetMap2 = insertedRecords.get(ref.targetObject);
232
+ if (!targetMap2?.has(String(fieldValue))) {
233
+ const error = {
234
+ sourceObject: objectName,
235
+ field: ref.field,
236
+ targetObject: ref.targetObject,
237
+ targetField: ref.targetField,
238
+ attemptedValue: fieldValue,
239
+ recordIndex: i,
240
+ message: `[dry-run] Reference may not resolve: ${objectName}.${ref.field} = '${fieldValue}' \u2192 ${ref.targetObject}.${ref.targetField}`
241
+ };
242
+ errors.push(error);
243
+ allErrors.push(error);
244
+ }
245
+ }
246
+ }
247
+ if (!config.dryRun) {
248
+ try {
249
+ const result = await this.writeRecord(
250
+ objectName,
251
+ record,
252
+ mode,
253
+ externalId,
254
+ existingRecords
255
+ );
256
+ if (result.action === "inserted") inserted++;
257
+ else if (result.action === "updated") updated++;
258
+ else if (result.action === "skipped") skipped++;
259
+ const externalIdValue = String(record[externalId] ?? "");
260
+ const internalId = result.id;
261
+ if (externalIdValue && internalId) {
262
+ insertedRecords.get(objectName).set(externalIdValue, String(internalId));
263
+ }
264
+ } catch (err) {
265
+ errored++;
266
+ this.logger.warn(`[SeedLoader] Failed to write ${objectName} record`, {
267
+ error: err.message,
268
+ recordIndex: i
269
+ });
270
+ }
271
+ } else {
272
+ const externalIdValue = String(record[externalId] ?? "");
273
+ if (externalIdValue) {
274
+ insertedRecords.get(objectName).set(externalIdValue, `dry-run-id-${i}`);
275
+ }
276
+ inserted++;
277
+ }
278
+ }
279
+ return {
280
+ object: objectName,
281
+ mode,
282
+ inserted,
283
+ updated,
284
+ skipped,
285
+ errored,
286
+ total: dataset.records.length,
287
+ referencesResolved,
288
+ referencesDeferred,
289
+ errors
290
+ };
291
+ }
292
+ // ==========================================================================
293
+ // Internal: Reference Resolution
294
+ // ==========================================================================
295
+ async resolveFromDatabase(targetObject, targetField, value) {
296
+ try {
297
+ const records = await this.engine.find(targetObject, {
298
+ filter: { [targetField]: value },
299
+ select: ["id"],
300
+ limit: 1
301
+ });
302
+ if (records && records.length > 0) {
303
+ return String(records[0].id || records[0]._id);
304
+ }
305
+ } catch {
306
+ }
307
+ return null;
308
+ }
309
+ async resolveDeferredUpdates(deferredUpdates, insertedRecords, allResults, allErrors) {
310
+ for (const deferred of deferredUpdates) {
311
+ const targetMap = insertedRecords.get(deferred.targetObject);
312
+ let resolvedId = targetMap?.get(String(deferred.attemptedValue));
313
+ if (!resolvedId) {
314
+ resolvedId = await this.resolveFromDatabase(
315
+ deferred.targetObject,
316
+ deferred.targetField,
317
+ deferred.attemptedValue
318
+ ) ?? void 0;
319
+ }
320
+ if (resolvedId) {
321
+ const objectRecordMap = insertedRecords.get(deferred.objectName);
322
+ const recordId = objectRecordMap?.get(deferred.recordExternalId);
323
+ if (recordId) {
324
+ try {
325
+ await this.engine.update(deferred.objectName, {
326
+ id: recordId,
327
+ [deferred.field]: resolvedId
328
+ });
329
+ const resultEntry = allResults.find((r) => r.object === deferred.objectName);
330
+ if (resultEntry) {
331
+ resultEntry.referencesResolved++;
332
+ resultEntry.referencesDeferred--;
333
+ }
334
+ } catch (err) {
335
+ this.logger.warn("[SeedLoader] Failed to resolve deferred reference", {
336
+ object: deferred.objectName,
337
+ field: deferred.field,
338
+ error: err.message
339
+ });
340
+ }
341
+ }
342
+ } else {
343
+ const error = {
344
+ sourceObject: deferred.objectName,
345
+ field: deferred.field,
346
+ targetObject: deferred.targetObject,
347
+ targetField: deferred.targetField,
348
+ attemptedValue: deferred.attemptedValue,
349
+ recordIndex: deferred.recordIndex,
350
+ message: `Deferred reference unresolved after pass 2: ${deferred.objectName}.${deferred.field} = '${deferred.attemptedValue}' \u2192 ${deferred.targetObject}.${deferred.targetField} not found`
351
+ };
352
+ const resultEntry = allResults.find((r) => r.object === deferred.objectName);
353
+ if (resultEntry) {
354
+ resultEntry.errors.push(error);
355
+ }
356
+ allErrors.push(error);
357
+ }
358
+ }
359
+ }
360
+ // ==========================================================================
361
+ // Internal: Write Operations
362
+ // ==========================================================================
363
+ async writeRecord(objectName, record, mode, externalId, existingRecords) {
364
+ const externalIdValue = record[externalId];
365
+ const existing = existingRecords?.get(String(externalIdValue ?? ""));
366
+ switch (mode) {
367
+ case "insert": {
368
+ const result = await this.engine.insert(objectName, record);
369
+ return { action: "inserted", id: this.extractId(result) };
370
+ }
371
+ case "update": {
372
+ if (!existing) {
373
+ return { action: "skipped" };
374
+ }
375
+ const id = this.extractId(existing);
376
+ await this.engine.update(objectName, { ...record, id });
377
+ return { action: "updated", id };
378
+ }
379
+ case "upsert": {
380
+ if (existing) {
381
+ const id = this.extractId(existing);
382
+ await this.engine.update(objectName, { ...record, id });
383
+ return { action: "updated", id };
384
+ } else {
385
+ const result = await this.engine.insert(objectName, record);
386
+ return { action: "inserted", id: this.extractId(result) };
387
+ }
388
+ }
389
+ case "ignore": {
390
+ if (existing) {
391
+ return { action: "skipped", id: this.extractId(existing) };
392
+ }
393
+ const result = await this.engine.insert(objectName, record);
394
+ return { action: "inserted", id: this.extractId(result) };
395
+ }
396
+ case "replace": {
397
+ const result = await this.engine.insert(objectName, record);
398
+ return { action: "inserted", id: this.extractId(result) };
399
+ }
400
+ default: {
401
+ const result = await this.engine.insert(objectName, record);
402
+ return { action: "inserted", id: this.extractId(result) };
403
+ }
404
+ }
405
+ }
406
+ // ==========================================================================
407
+ // Internal: Dependency Graph
408
+ // ==========================================================================
409
+ /**
410
+ * Kahn's algorithm for topological sort with cycle detection.
411
+ */
412
+ topologicalSort(nodes) {
413
+ const inDegree = /* @__PURE__ */ new Map();
414
+ const adjacency = /* @__PURE__ */ new Map();
415
+ const objectSet = new Set(nodes.map((n) => n.object));
416
+ for (const node of nodes) {
417
+ inDegree.set(node.object, 0);
418
+ adjacency.set(node.object, []);
419
+ }
420
+ for (const node of nodes) {
421
+ for (const dep of node.dependsOn) {
422
+ if (objectSet.has(dep) && dep !== node.object) {
423
+ adjacency.get(dep).push(node.object);
424
+ inDegree.set(node.object, (inDegree.get(node.object) || 0) + 1);
425
+ }
426
+ }
427
+ }
428
+ const queue = [];
429
+ for (const [obj, degree] of inDegree) {
430
+ if (degree === 0) queue.push(obj);
431
+ }
432
+ const insertOrder = [];
433
+ while (queue.length > 0) {
434
+ const current = queue.shift();
435
+ insertOrder.push(current);
436
+ for (const neighbor of adjacency.get(current) || []) {
437
+ const newDegree = (inDegree.get(neighbor) || 0) - 1;
438
+ inDegree.set(neighbor, newDegree);
439
+ if (newDegree === 0) {
440
+ queue.push(neighbor);
441
+ }
442
+ }
443
+ }
444
+ const circularDependencies = [];
445
+ const remaining = nodes.filter((n) => !insertOrder.includes(n.object));
446
+ if (remaining.length > 0) {
447
+ const cycles = this.findCycles(remaining);
448
+ circularDependencies.push(...cycles);
449
+ for (const node of remaining) {
450
+ if (!insertOrder.includes(node.object)) {
451
+ insertOrder.push(node.object);
452
+ }
453
+ }
454
+ }
455
+ return { insertOrder, circularDependencies };
456
+ }
457
+ findCycles(nodes) {
458
+ const cycles = [];
459
+ const nodeMap = new Map(nodes.map((n) => [n.object, n]));
460
+ const visited = /* @__PURE__ */ new Set();
461
+ const inStack = /* @__PURE__ */ new Set();
462
+ const dfs = (current, path) => {
463
+ if (inStack.has(current)) {
464
+ const cycleStart = path.indexOf(current);
465
+ if (cycleStart !== -1) {
466
+ cycles.push([...path.slice(cycleStart), current]);
467
+ }
468
+ return;
469
+ }
470
+ if (visited.has(current)) return;
471
+ visited.add(current);
472
+ inStack.add(current);
473
+ path.push(current);
474
+ const node = nodeMap.get(current);
475
+ if (node) {
476
+ for (const dep of node.dependsOn) {
477
+ if (nodeMap.has(dep)) {
478
+ dfs(dep, [...path]);
479
+ }
480
+ }
481
+ }
482
+ inStack.delete(current);
483
+ };
484
+ for (const node of nodes) {
485
+ if (!visited.has(node.object)) {
486
+ dfs(node.object, []);
487
+ }
488
+ }
489
+ return cycles;
490
+ }
491
+ // ==========================================================================
492
+ // Internal: Helpers
493
+ // ==========================================================================
494
+ filterByEnv(datasets, env) {
495
+ if (!env) return datasets;
496
+ return datasets.filter((d) => d.env.includes(env));
497
+ }
498
+ orderDatasets(datasets, insertOrder) {
499
+ const orderMap = new Map(insertOrder.map((name, i) => [name, i]));
500
+ return [...datasets].sort((a, b) => {
501
+ const orderA = orderMap.get(a.object) ?? Number.MAX_SAFE_INTEGER;
502
+ const orderB = orderMap.get(b.object) ?? Number.MAX_SAFE_INTEGER;
503
+ return orderA - orderB;
504
+ });
505
+ }
506
+ buildReferenceMap(graph) {
507
+ const map = /* @__PURE__ */ new Map();
508
+ for (const node of graph.nodes) {
509
+ if (node.references.length > 0) {
510
+ map.set(node.object, node.references);
511
+ }
512
+ }
513
+ return map;
514
+ }
515
+ async loadExistingRecords(objectName, externalId) {
516
+ const map = /* @__PURE__ */ new Map();
517
+ try {
518
+ const records = await this.engine.find(objectName, {
519
+ select: ["id", externalId]
520
+ });
521
+ for (const record of records || []) {
522
+ const key = String(record[externalId] ?? "");
523
+ if (key) {
524
+ map.set(key, record);
525
+ }
526
+ }
527
+ } catch {
528
+ }
529
+ return map;
530
+ }
531
+ looksLikeInternalId(value) {
532
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) {
533
+ return true;
534
+ }
535
+ if (/^[0-9a-f]{24}$/i.test(value)) {
536
+ return true;
537
+ }
538
+ return false;
539
+ }
540
+ extractId(record) {
541
+ if (!record) return void 0;
542
+ return String(record.id || record._id || "");
543
+ }
544
+ buildEmptyResult(config, durationMs) {
545
+ return {
546
+ success: true,
547
+ dryRun: config.dryRun,
548
+ dependencyGraph: { nodes: [], insertOrder: [], circularDependencies: [] },
549
+ results: [],
550
+ errors: [],
551
+ summary: {
552
+ objectsProcessed: 0,
553
+ totalRecords: 0,
554
+ totalInserted: 0,
555
+ totalUpdated: 0,
556
+ totalSkipped: 0,
557
+ totalErrored: 0,
558
+ totalReferencesResolved: 0,
559
+ totalReferencesDeferred: 0,
560
+ circularDependencyCount: 0,
561
+ durationMs
562
+ }
563
+ };
564
+ }
565
+ buildResult(config, graph, results, errors, durationMs) {
566
+ const summary = {
567
+ objectsProcessed: results.length,
568
+ totalRecords: results.reduce((sum, r) => sum + r.total, 0),
569
+ totalInserted: results.reduce((sum, r) => sum + r.inserted, 0),
570
+ totalUpdated: results.reduce((sum, r) => sum + r.updated, 0),
571
+ totalSkipped: results.reduce((sum, r) => sum + r.skipped, 0),
572
+ totalErrored: results.reduce((sum, r) => sum + r.errored, 0),
573
+ totalReferencesResolved: results.reduce((sum, r) => sum + r.referencesResolved, 0),
574
+ totalReferencesDeferred: results.reduce((sum, r) => sum + r.referencesDeferred, 0),
575
+ circularDependencyCount: graph.circularDependencies.length,
576
+ durationMs
577
+ };
578
+ const hasErrors = errors.length > 0 || summary.totalErrored > 0;
579
+ return {
580
+ success: !hasErrors,
581
+ dryRun: config.dryRun,
582
+ dependencyGraph: graph,
583
+ results,
584
+ errors,
585
+ summary
586
+ };
587
+ }
588
+ };
589
+
78
590
  // src/app-plugin.ts
79
591
  var AppPlugin = class {
80
592
  constructor(bundle) {
@@ -144,20 +656,52 @@ var AppPlugin = class {
144
656
  };
145
657
  if (seedDatasets.length > 0) {
146
658
  ctx.logger.info(`[AppPlugin] Found ${seedDatasets.length} seed datasets for ${appId}`);
147
- for (const dataset of seedDatasets) {
148
- if (dataset.object && Array.isArray(dataset.records)) {
149
- const objectFQN = toFQN(dataset.object);
150
- ctx.logger.info(`[Seeder] Seeding ${dataset.records.length} records for ${objectFQN}`);
659
+ const normalizedDatasets = seedDatasets.filter((d) => d.object && Array.isArray(d.records)).map((d) => ({
660
+ ...d,
661
+ object: toFQN(d.object)
662
+ }));
663
+ try {
664
+ const metadata = ctx.getService("metadata");
665
+ if (metadata) {
666
+ const seedLoader = new SeedLoaderService(ql, metadata, ctx.logger);
667
+ const { SeedLoaderRequestSchema } = await import("@objectstack/spec/data");
668
+ const request = SeedLoaderRequestSchema.parse({
669
+ datasets: normalizedDatasets,
670
+ config: { defaultMode: "upsert", multiPass: true }
671
+ });
672
+ const result = await seedLoader.load(request);
673
+ ctx.logger.info("[Seeder] Seed loading complete", {
674
+ inserted: result.summary.totalInserted,
675
+ updated: result.summary.totalUpdated,
676
+ errors: result.errors.length
677
+ });
678
+ } else {
679
+ ctx.logger.debug("[Seeder] No metadata service; using basic insert fallback");
680
+ for (const dataset of normalizedDatasets) {
681
+ ctx.logger.info(`[Seeder] Seeding ${dataset.records.length} records for ${dataset.object}`);
682
+ for (const record of dataset.records) {
683
+ try {
684
+ await ql.insert(dataset.object, record);
685
+ } catch (err) {
686
+ ctx.logger.warn(`[Seeder] Failed to insert ${dataset.object} record:`, { error: err.message });
687
+ }
688
+ }
689
+ }
690
+ ctx.logger.info("[Seeder] Data seeding complete.");
691
+ }
692
+ } catch (err) {
693
+ ctx.logger.warn("[Seeder] SeedLoaderService failed, falling back to basic insert", { error: err.message });
694
+ for (const dataset of normalizedDatasets) {
151
695
  for (const record of dataset.records) {
152
696
  try {
153
- await ql.insert(objectFQN, record);
154
- } catch (err) {
155
- ctx.logger.warn(`[Seeder] Failed to insert ${objectFQN} record:`, { error: err.message });
697
+ await ql.insert(dataset.object, record);
698
+ } catch (insertErr) {
699
+ ctx.logger.warn(`[Seeder] Failed to insert ${dataset.object} record:`, { error: insertErr.message });
156
700
  }
157
701
  }
158
702
  }
703
+ ctx.logger.info("[Seeder] Data seeding complete (fallback).");
159
704
  }
160
- ctx.logger.info("[Seeder] Data seeding complete.");
161
705
  }
162
706
  };
163
707
  this.bundle = bundle;
@@ -1616,6 +2160,7 @@ export {
1616
2160
  RouteGroupBuilder,
1617
2161
  RouteManager,
1618
2162
  Runtime,
2163
+ SeedLoaderService,
1619
2164
  createDispatcherPlugin,
1620
2165
  createRestApiPlugin
1621
2166
  };