@objectstack/objectql 8.0.1 → 9.0.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/dist/index.d.mts +93 -5
- package/dist/index.d.ts +93 -5
- package/dist/index.js +698 -19
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +689 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,616 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __esm = (fn, res) => function __init() {
|
|
4
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
5
|
+
};
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// src/seed-loader.ts
|
|
12
|
+
var seed_loader_exports = {};
|
|
13
|
+
__export(seed_loader_exports, {
|
|
14
|
+
SeedLoaderService: () => SeedLoaderService
|
|
15
|
+
});
|
|
16
|
+
import { SeedLoaderConfigSchema } from "@objectstack/spec/data";
|
|
17
|
+
import { resolveSeedRecord } from "@objectstack/formula";
|
|
18
|
+
var DEFAULT_EXTERNAL_ID_FIELD, _SeedLoaderService, SeedLoaderService;
|
|
19
|
+
var init_seed_loader = __esm({
|
|
20
|
+
"src/seed-loader.ts"() {
|
|
21
|
+
"use strict";
|
|
22
|
+
DEFAULT_EXTERNAL_ID_FIELD = "name";
|
|
23
|
+
_SeedLoaderService = class _SeedLoaderService {
|
|
24
|
+
constructor(engine, metadata, logger) {
|
|
25
|
+
this.engine = engine;
|
|
26
|
+
this.metadata = metadata;
|
|
27
|
+
this.logger = logger;
|
|
28
|
+
}
|
|
29
|
+
// ==========================================================================
|
|
30
|
+
// Public API
|
|
31
|
+
// ==========================================================================
|
|
32
|
+
async load(request) {
|
|
33
|
+
const startTime = Date.now();
|
|
34
|
+
const config = request.config;
|
|
35
|
+
const allErrors = [];
|
|
36
|
+
const allResults = [];
|
|
37
|
+
const datasets = this.filterByEnv(request.seeds, config.env);
|
|
38
|
+
if (datasets.length === 0) {
|
|
39
|
+
return this.buildEmptyResult(config, Date.now() - startTime);
|
|
40
|
+
}
|
|
41
|
+
const objectNames = datasets.map((d) => d.object);
|
|
42
|
+
const graph = await this.buildDependencyGraph(objectNames);
|
|
43
|
+
this.logger.info("[SeedLoader] Dependency graph built", {
|
|
44
|
+
objects: objectNames.length,
|
|
45
|
+
insertOrder: graph.insertOrder,
|
|
46
|
+
circularDeps: graph.circularDependencies.length
|
|
47
|
+
});
|
|
48
|
+
const orderedDatasets = this.orderDatasets(datasets, graph.insertOrder);
|
|
49
|
+
const refMap = this.buildReferenceMap(graph);
|
|
50
|
+
const insertedRecords = /* @__PURE__ */ new Map();
|
|
51
|
+
const deferredUpdates = [];
|
|
52
|
+
for (const dataset of orderedDatasets) {
|
|
53
|
+
const result = await this.loadDataset(
|
|
54
|
+
dataset,
|
|
55
|
+
config,
|
|
56
|
+
refMap,
|
|
57
|
+
insertedRecords,
|
|
58
|
+
deferredUpdates,
|
|
59
|
+
allErrors
|
|
60
|
+
);
|
|
61
|
+
allResults.push(result);
|
|
62
|
+
if (config.haltOnError && result.errored > 0) {
|
|
63
|
+
this.logger.warn("[SeedLoader] Halting on first error", { object: dataset.object });
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (config.multiPass && deferredUpdates.length > 0 && !config.dryRun) {
|
|
68
|
+
this.logger.info("[SeedLoader] Pass 2: resolving deferred references", {
|
|
69
|
+
count: deferredUpdates.length
|
|
70
|
+
});
|
|
71
|
+
await this.resolveDeferredUpdates(deferredUpdates, insertedRecords, allResults, allErrors, config.organizationId);
|
|
72
|
+
}
|
|
73
|
+
const durationMs = Date.now() - startTime;
|
|
74
|
+
return this.buildResult(config, graph, allResults, allErrors, durationMs);
|
|
75
|
+
}
|
|
76
|
+
async buildDependencyGraph(objectNames) {
|
|
77
|
+
const nodes = [];
|
|
78
|
+
const objectSet = new Set(objectNames);
|
|
79
|
+
for (const objectName of objectNames) {
|
|
80
|
+
const objDef = await this.metadata.getObject(objectName);
|
|
81
|
+
const dependsOn = [];
|
|
82
|
+
const references = [];
|
|
83
|
+
if (objDef && objDef.fields) {
|
|
84
|
+
const fields = objDef.fields;
|
|
85
|
+
for (const [fieldName, fieldDef] of Object.entries(fields)) {
|
|
86
|
+
if ((fieldDef.type === "lookup" || fieldDef.type === "master_detail") && fieldDef.reference) {
|
|
87
|
+
const targetObject = fieldDef.reference;
|
|
88
|
+
if (objectSet.has(targetObject) && !dependsOn.includes(targetObject)) {
|
|
89
|
+
dependsOn.push(targetObject);
|
|
90
|
+
}
|
|
91
|
+
references.push({
|
|
92
|
+
field: fieldName,
|
|
93
|
+
targetObject,
|
|
94
|
+
targetField: DEFAULT_EXTERNAL_ID_FIELD,
|
|
95
|
+
fieldType: fieldDef.type
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
nodes.push({ object: objectName, dependsOn, references });
|
|
101
|
+
}
|
|
102
|
+
const { insertOrder, circularDependencies } = this.topologicalSort(nodes);
|
|
103
|
+
return { nodes, insertOrder, circularDependencies };
|
|
104
|
+
}
|
|
105
|
+
async validate(datasets, config) {
|
|
106
|
+
const parsedConfig = SeedLoaderConfigSchema.parse({ ...config, dryRun: true });
|
|
107
|
+
return this.load({ seeds: datasets, config: parsedConfig });
|
|
108
|
+
}
|
|
109
|
+
// ==========================================================================
|
|
110
|
+
// Internal: Seed Loading
|
|
111
|
+
// ==========================================================================
|
|
112
|
+
async loadDataset(dataset, config, refMap, insertedRecords, deferredUpdates, allErrors) {
|
|
113
|
+
const objectName = dataset.object;
|
|
114
|
+
const mode = dataset.mode || config.defaultMode;
|
|
115
|
+
const externalId = dataset.externalId || "name";
|
|
116
|
+
let inserted = 0;
|
|
117
|
+
let updated = 0;
|
|
118
|
+
let skipped = 0;
|
|
119
|
+
let errored = 0;
|
|
120
|
+
let referencesResolved = 0;
|
|
121
|
+
let referencesDeferred = 0;
|
|
122
|
+
const errors = [];
|
|
123
|
+
if (!insertedRecords.has(objectName)) {
|
|
124
|
+
insertedRecords.set(objectName, /* @__PURE__ */ new Map());
|
|
125
|
+
}
|
|
126
|
+
let existingRecords;
|
|
127
|
+
if ((mode === "upsert" || mode === "update" || mode === "ignore") && !config.dryRun) {
|
|
128
|
+
existingRecords = await this.loadExistingRecords(
|
|
129
|
+
objectName,
|
|
130
|
+
externalId,
|
|
131
|
+
config.organizationId
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const objectRefs = refMap.get(objectName) || [];
|
|
135
|
+
const seedNow = /* @__PURE__ */ new Date();
|
|
136
|
+
const seedIdentity = config.identity;
|
|
137
|
+
const baseEvalCtx = {
|
|
138
|
+
now: seedNow,
|
|
139
|
+
user: seedIdentity?.user,
|
|
140
|
+
// Fall back to the per-tenant organizationId so `os.org.id` resolves
|
|
141
|
+
// during per-org replay even without an explicit identity.org.
|
|
142
|
+
org: seedIdentity?.org ?? (config.organizationId ? { id: config.organizationId } : void 0),
|
|
143
|
+
env: config.env
|
|
144
|
+
};
|
|
145
|
+
for (let i = 0; i < dataset.records.length; i++) {
|
|
146
|
+
const seedResult = resolveSeedRecord(
|
|
147
|
+
dataset.records[i],
|
|
148
|
+
baseEvalCtx
|
|
149
|
+
);
|
|
150
|
+
if (!seedResult.ok) {
|
|
151
|
+
errored++;
|
|
152
|
+
const error = {
|
|
153
|
+
sourceObject: objectName,
|
|
154
|
+
field: "(expression)",
|
|
155
|
+
targetObject: objectName,
|
|
156
|
+
targetField: "(expression)",
|
|
157
|
+
attemptedValue: dataset.records[i],
|
|
158
|
+
recordIndex: i,
|
|
159
|
+
message: `Cannot resolve dynamic seed values for ${objectName} record #${i}: ${seedResult.error.message}. Records using cel\`os.user.id\` / cel\`os.org.id\` require a seed identity \u2014 ensure a system/admin user exists before seeding (see SeedLoaderConfig.identity).`
|
|
160
|
+
};
|
|
161
|
+
errors.push(error);
|
|
162
|
+
allErrors.push(error);
|
|
163
|
+
this.logger.warn(`[SeedLoader] ${error.message}`);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const record = { ...seedResult.value };
|
|
167
|
+
if (config.organizationId && record["organization_id"] == null) {
|
|
168
|
+
record["organization_id"] = config.organizationId;
|
|
169
|
+
}
|
|
170
|
+
for (const ref of objectRefs) {
|
|
171
|
+
const fieldValue = record[ref.field];
|
|
172
|
+
if (fieldValue === void 0 || fieldValue === null) continue;
|
|
173
|
+
if (typeof fieldValue === "object") {
|
|
174
|
+
const wrapped = fieldValue.externalId;
|
|
175
|
+
const hint = wrapped !== void 0 ? ` Pass the natural key directly: ${ref.field}: ${JSON.stringify(wrapped)}.` : ` Pass the target's ${ref.targetField} value as a plain string.`;
|
|
176
|
+
const error = {
|
|
177
|
+
sourceObject: objectName,
|
|
178
|
+
field: ref.field,
|
|
179
|
+
targetObject: ref.targetObject,
|
|
180
|
+
targetField: ref.targetField,
|
|
181
|
+
attemptedValue: fieldValue,
|
|
182
|
+
recordIndex: i,
|
|
183
|
+
message: `Invalid reference for ${objectName}.${ref.field}: expected a ${ref.targetObject}.${ref.targetField} natural-key string but got an object.${hint}`
|
|
184
|
+
};
|
|
185
|
+
errors.push(error);
|
|
186
|
+
allErrors.push(error);
|
|
187
|
+
this.logger.warn(`[SeedLoader] ${error.message}`, { recordIndex: i });
|
|
188
|
+
record[ref.field] = null;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (typeof fieldValue !== "string" || this.looksLikeInternalId(fieldValue)) continue;
|
|
192
|
+
const targetMap = insertedRecords.get(ref.targetObject);
|
|
193
|
+
const resolvedId = targetMap?.get(String(fieldValue));
|
|
194
|
+
if (resolvedId) {
|
|
195
|
+
record[ref.field] = resolvedId;
|
|
196
|
+
referencesResolved++;
|
|
197
|
+
} else if (!config.dryRun) {
|
|
198
|
+
const dbId = await this.resolveFromDatabase(ref.targetObject, ref.targetField, fieldValue, config.organizationId);
|
|
199
|
+
if (dbId) {
|
|
200
|
+
record[ref.field] = dbId;
|
|
201
|
+
referencesResolved++;
|
|
202
|
+
} else if (config.multiPass) {
|
|
203
|
+
record[ref.field] = null;
|
|
204
|
+
deferredUpdates.push({
|
|
205
|
+
objectName,
|
|
206
|
+
recordExternalId: String(record[externalId] ?? ""),
|
|
207
|
+
field: ref.field,
|
|
208
|
+
targetObject: ref.targetObject,
|
|
209
|
+
targetField: ref.targetField,
|
|
210
|
+
attemptedValue: fieldValue,
|
|
211
|
+
recordIndex: i
|
|
212
|
+
});
|
|
213
|
+
referencesDeferred++;
|
|
214
|
+
} else {
|
|
215
|
+
const error = {
|
|
216
|
+
sourceObject: objectName,
|
|
217
|
+
field: ref.field,
|
|
218
|
+
targetObject: ref.targetObject,
|
|
219
|
+
targetField: ref.targetField,
|
|
220
|
+
attemptedValue: fieldValue,
|
|
221
|
+
recordIndex: i,
|
|
222
|
+
message: `Cannot resolve reference: ${objectName}.${ref.field} = '${fieldValue}' \u2192 ${ref.targetObject}.${ref.targetField} not found`
|
|
223
|
+
};
|
|
224
|
+
errors.push(error);
|
|
225
|
+
allErrors.push(error);
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
const targetMap2 = insertedRecords.get(ref.targetObject);
|
|
229
|
+
if (!targetMap2?.has(String(fieldValue))) {
|
|
230
|
+
const error = {
|
|
231
|
+
sourceObject: objectName,
|
|
232
|
+
field: ref.field,
|
|
233
|
+
targetObject: ref.targetObject,
|
|
234
|
+
targetField: ref.targetField,
|
|
235
|
+
attemptedValue: fieldValue,
|
|
236
|
+
recordIndex: i,
|
|
237
|
+
message: `[dry-run] Reference may not resolve: ${objectName}.${ref.field} = '${fieldValue}' \u2192 ${ref.targetObject}.${ref.targetField}`
|
|
238
|
+
};
|
|
239
|
+
errors.push(error);
|
|
240
|
+
allErrors.push(error);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (!config.dryRun) {
|
|
245
|
+
try {
|
|
246
|
+
const result = await this.writeRecord(
|
|
247
|
+
objectName,
|
|
248
|
+
record,
|
|
249
|
+
mode,
|
|
250
|
+
externalId,
|
|
251
|
+
existingRecords
|
|
252
|
+
);
|
|
253
|
+
if (result.action === "inserted") inserted++;
|
|
254
|
+
else if (result.action === "updated") updated++;
|
|
255
|
+
else if (result.action === "skipped") skipped++;
|
|
256
|
+
const externalIdValue = String(record[externalId] ?? "");
|
|
257
|
+
const internalId = result.id;
|
|
258
|
+
if (externalIdValue && internalId) {
|
|
259
|
+
insertedRecords.get(objectName).set(externalIdValue, String(internalId));
|
|
260
|
+
}
|
|
261
|
+
} catch (err) {
|
|
262
|
+
errored++;
|
|
263
|
+
const error = {
|
|
264
|
+
sourceObject: objectName,
|
|
265
|
+
field: "(write)",
|
|
266
|
+
targetObject: objectName,
|
|
267
|
+
targetField: externalId,
|
|
268
|
+
attemptedValue: record[externalId] ?? null,
|
|
269
|
+
recordIndex: i,
|
|
270
|
+
message: `Failed to write ${objectName} record #${i} (${externalId}=${String(record[externalId] ?? "")}): ${err.message}`
|
|
271
|
+
};
|
|
272
|
+
errors.push(error);
|
|
273
|
+
allErrors.push(error);
|
|
274
|
+
this.logger.warn(`[SeedLoader] ${error.message}`, { recordIndex: i });
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
const externalIdValue = String(record[externalId] ?? "");
|
|
278
|
+
if (externalIdValue) {
|
|
279
|
+
insertedRecords.get(objectName).set(externalIdValue, `dry-run-id-${i}`);
|
|
280
|
+
}
|
|
281
|
+
inserted++;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
object: objectName,
|
|
286
|
+
mode,
|
|
287
|
+
inserted,
|
|
288
|
+
updated,
|
|
289
|
+
skipped,
|
|
290
|
+
errored,
|
|
291
|
+
total: dataset.records.length,
|
|
292
|
+
referencesResolved,
|
|
293
|
+
referencesDeferred,
|
|
294
|
+
errors
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
// ==========================================================================
|
|
298
|
+
// Internal: Reference Resolution
|
|
299
|
+
// ==========================================================================
|
|
300
|
+
async resolveFromDatabase(targetObject, targetField, value, organizationId) {
|
|
301
|
+
try {
|
|
302
|
+
const where = { [targetField]: value };
|
|
303
|
+
if (organizationId) where.organization_id = organizationId;
|
|
304
|
+
const records = await this.engine.find(targetObject, {
|
|
305
|
+
where,
|
|
306
|
+
fields: ["id"],
|
|
307
|
+
limit: 1,
|
|
308
|
+
context: { isSystem: true }
|
|
309
|
+
});
|
|
310
|
+
if (records && records.length > 0) {
|
|
311
|
+
return String(records[0].id || records[0]._id);
|
|
312
|
+
}
|
|
313
|
+
} catch {
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
async resolveDeferredUpdates(deferredUpdates, insertedRecords, allResults, allErrors, organizationId) {
|
|
318
|
+
for (const deferred of deferredUpdates) {
|
|
319
|
+
const targetMap = insertedRecords.get(deferred.targetObject);
|
|
320
|
+
let resolvedId = targetMap?.get(String(deferred.attemptedValue));
|
|
321
|
+
if (!resolvedId) {
|
|
322
|
+
resolvedId = await this.resolveFromDatabase(
|
|
323
|
+
deferred.targetObject,
|
|
324
|
+
deferred.targetField,
|
|
325
|
+
deferred.attemptedValue,
|
|
326
|
+
organizationId
|
|
327
|
+
) ?? void 0;
|
|
328
|
+
}
|
|
329
|
+
if (resolvedId) {
|
|
330
|
+
const objectRecordMap = insertedRecords.get(deferred.objectName);
|
|
331
|
+
const recordId = objectRecordMap?.get(deferred.recordExternalId);
|
|
332
|
+
if (recordId) {
|
|
333
|
+
try {
|
|
334
|
+
await this.engine.update(deferred.objectName, {
|
|
335
|
+
id: recordId,
|
|
336
|
+
[deferred.field]: resolvedId
|
|
337
|
+
}, { context: { isSystem: true } });
|
|
338
|
+
const resultEntry = allResults.find((r) => r.object === deferred.objectName);
|
|
339
|
+
if (resultEntry) {
|
|
340
|
+
resultEntry.referencesResolved++;
|
|
341
|
+
resultEntry.referencesDeferred--;
|
|
342
|
+
}
|
|
343
|
+
} catch (err) {
|
|
344
|
+
this.logger.warn("[SeedLoader] Failed to resolve deferred reference", {
|
|
345
|
+
object: deferred.objectName,
|
|
346
|
+
field: deferred.field,
|
|
347
|
+
error: err.message
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
const error = {
|
|
353
|
+
sourceObject: deferred.objectName,
|
|
354
|
+
field: deferred.field,
|
|
355
|
+
targetObject: deferred.targetObject,
|
|
356
|
+
targetField: deferred.targetField,
|
|
357
|
+
attemptedValue: deferred.attemptedValue,
|
|
358
|
+
recordIndex: deferred.recordIndex,
|
|
359
|
+
message: `Deferred reference unresolved after pass 2: ${deferred.objectName}.${deferred.field} = '${deferred.attemptedValue}' \u2192 ${deferred.targetObject}.${deferred.targetField} not found`
|
|
360
|
+
};
|
|
361
|
+
const resultEntry = allResults.find((r) => r.object === deferred.objectName);
|
|
362
|
+
if (resultEntry) {
|
|
363
|
+
resultEntry.errors.push(error);
|
|
364
|
+
}
|
|
365
|
+
allErrors.push(error);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async writeRecord(objectName, record, mode, externalId, existingRecords) {
|
|
370
|
+
const externalIdValue = record[externalId];
|
|
371
|
+
const existing = existingRecords?.get(String(externalIdValue ?? ""));
|
|
372
|
+
const opts = _SeedLoaderService.SEED_OPTIONS;
|
|
373
|
+
switch (mode) {
|
|
374
|
+
case "insert": {
|
|
375
|
+
const result = await this.engine.insert(objectName, record, opts);
|
|
376
|
+
return { action: "inserted", id: this.extractId(result) };
|
|
377
|
+
}
|
|
378
|
+
case "update": {
|
|
379
|
+
if (!existing) {
|
|
380
|
+
return { action: "skipped" };
|
|
381
|
+
}
|
|
382
|
+
const id = this.extractId(existing);
|
|
383
|
+
await this.engine.update(objectName, { ...record, id }, opts);
|
|
384
|
+
return { action: "updated", id };
|
|
385
|
+
}
|
|
386
|
+
case "upsert": {
|
|
387
|
+
if (existing) {
|
|
388
|
+
const id = this.extractId(existing);
|
|
389
|
+
await this.engine.update(objectName, { ...record, id }, opts);
|
|
390
|
+
return { action: "updated", id };
|
|
391
|
+
} else {
|
|
392
|
+
const result = await this.engine.insert(objectName, record, opts);
|
|
393
|
+
return { action: "inserted", id: this.extractId(result) };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
case "ignore": {
|
|
397
|
+
if (existing) {
|
|
398
|
+
return { action: "skipped", id: this.extractId(existing) };
|
|
399
|
+
}
|
|
400
|
+
const result = await this.engine.insert(objectName, record, opts);
|
|
401
|
+
return { action: "inserted", id: this.extractId(result) };
|
|
402
|
+
}
|
|
403
|
+
case "replace": {
|
|
404
|
+
const result = await this.engine.insert(objectName, record, opts);
|
|
405
|
+
return { action: "inserted", id: this.extractId(result) };
|
|
406
|
+
}
|
|
407
|
+
default: {
|
|
408
|
+
const result = await this.engine.insert(objectName, record, opts);
|
|
409
|
+
return { action: "inserted", id: this.extractId(result) };
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// ==========================================================================
|
|
414
|
+
// Internal: Dependency Graph
|
|
415
|
+
// ==========================================================================
|
|
416
|
+
/**
|
|
417
|
+
* Kahn's algorithm for topological sort with cycle detection.
|
|
418
|
+
*/
|
|
419
|
+
topologicalSort(nodes) {
|
|
420
|
+
const inDegree = /* @__PURE__ */ new Map();
|
|
421
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
422
|
+
const objectSet = new Set(nodes.map((n) => n.object));
|
|
423
|
+
for (const node of nodes) {
|
|
424
|
+
inDegree.set(node.object, 0);
|
|
425
|
+
adjacency.set(node.object, []);
|
|
426
|
+
}
|
|
427
|
+
for (const node of nodes) {
|
|
428
|
+
for (const dep of node.dependsOn) {
|
|
429
|
+
if (objectSet.has(dep) && dep !== node.object) {
|
|
430
|
+
adjacency.get(dep).push(node.object);
|
|
431
|
+
inDegree.set(node.object, (inDegree.get(node.object) || 0) + 1);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const queue = [];
|
|
436
|
+
for (const [obj, degree] of inDegree) {
|
|
437
|
+
if (degree === 0) queue.push(obj);
|
|
438
|
+
}
|
|
439
|
+
const insertOrder = [];
|
|
440
|
+
while (queue.length > 0) {
|
|
441
|
+
const current = queue.shift();
|
|
442
|
+
insertOrder.push(current);
|
|
443
|
+
for (const neighbor of adjacency.get(current) || []) {
|
|
444
|
+
const newDegree = (inDegree.get(neighbor) || 0) - 1;
|
|
445
|
+
inDegree.set(neighbor, newDegree);
|
|
446
|
+
if (newDegree === 0) {
|
|
447
|
+
queue.push(neighbor);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const circularDependencies = [];
|
|
452
|
+
const remaining = nodes.filter((n) => !insertOrder.includes(n.object));
|
|
453
|
+
if (remaining.length > 0) {
|
|
454
|
+
const cycles = this.findCycles(remaining);
|
|
455
|
+
circularDependencies.push(...cycles);
|
|
456
|
+
for (const node of remaining) {
|
|
457
|
+
if (!insertOrder.includes(node.object)) {
|
|
458
|
+
insertOrder.push(node.object);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return { insertOrder, circularDependencies };
|
|
463
|
+
}
|
|
464
|
+
findCycles(nodes) {
|
|
465
|
+
const cycles = [];
|
|
466
|
+
const nodeMap = new Map(nodes.map((n) => [n.object, n]));
|
|
467
|
+
const visited = /* @__PURE__ */ new Set();
|
|
468
|
+
const inStack = /* @__PURE__ */ new Set();
|
|
469
|
+
const dfs = (current, path) => {
|
|
470
|
+
if (inStack.has(current)) {
|
|
471
|
+
const cycleStart = path.indexOf(current);
|
|
472
|
+
if (cycleStart !== -1) {
|
|
473
|
+
cycles.push([...path.slice(cycleStart), current]);
|
|
474
|
+
}
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (visited.has(current)) return;
|
|
478
|
+
visited.add(current);
|
|
479
|
+
inStack.add(current);
|
|
480
|
+
path.push(current);
|
|
481
|
+
const node = nodeMap.get(current);
|
|
482
|
+
if (node) {
|
|
483
|
+
for (const dep of node.dependsOn) {
|
|
484
|
+
if (nodeMap.has(dep)) {
|
|
485
|
+
dfs(dep, [...path]);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
inStack.delete(current);
|
|
490
|
+
};
|
|
491
|
+
for (const node of nodes) {
|
|
492
|
+
if (!visited.has(node.object)) {
|
|
493
|
+
dfs(node.object, []);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return cycles;
|
|
497
|
+
}
|
|
498
|
+
// ==========================================================================
|
|
499
|
+
// Internal: Helpers
|
|
500
|
+
// ==========================================================================
|
|
501
|
+
filterByEnv(datasets, env) {
|
|
502
|
+
if (!env) return datasets;
|
|
503
|
+
return datasets.filter((d) => d.env.includes(env));
|
|
504
|
+
}
|
|
505
|
+
orderDatasets(datasets, insertOrder) {
|
|
506
|
+
const orderMap = new Map(insertOrder.map((name, i) => [name, i]));
|
|
507
|
+
return [...datasets].sort((a, b) => {
|
|
508
|
+
const orderA = orderMap.get(a.object) ?? Number.MAX_SAFE_INTEGER;
|
|
509
|
+
const orderB = orderMap.get(b.object) ?? Number.MAX_SAFE_INTEGER;
|
|
510
|
+
return orderA - orderB;
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
buildReferenceMap(graph) {
|
|
514
|
+
const map = /* @__PURE__ */ new Map();
|
|
515
|
+
for (const node of graph.nodes) {
|
|
516
|
+
if (node.references.length > 0) {
|
|
517
|
+
map.set(node.object, node.references);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return map;
|
|
521
|
+
}
|
|
522
|
+
async loadExistingRecords(objectName, externalId, organizationId) {
|
|
523
|
+
const map = /* @__PURE__ */ new Map();
|
|
524
|
+
try {
|
|
525
|
+
const findArgs = {
|
|
526
|
+
fields: ["id", externalId],
|
|
527
|
+
context: { isSystem: true }
|
|
528
|
+
};
|
|
529
|
+
if (organizationId) findArgs.where = { organization_id: organizationId };
|
|
530
|
+
const records = await this.engine.find(objectName, findArgs);
|
|
531
|
+
for (const record of records || []) {
|
|
532
|
+
const key = String(record[externalId] ?? "");
|
|
533
|
+
if (key) {
|
|
534
|
+
map.set(key, record);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
} catch {
|
|
538
|
+
}
|
|
539
|
+
return map;
|
|
540
|
+
}
|
|
541
|
+
looksLikeInternalId(value) {
|
|
542
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) {
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
if (/^[0-9a-f]{24}$/i.test(value)) {
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
extractId(record) {
|
|
551
|
+
if (!record) return void 0;
|
|
552
|
+
return String(record.id || record._id || "");
|
|
553
|
+
}
|
|
554
|
+
buildEmptyResult(config, durationMs) {
|
|
555
|
+
return {
|
|
556
|
+
success: true,
|
|
557
|
+
dryRun: config.dryRun,
|
|
558
|
+
dependencyGraph: { nodes: [], insertOrder: [], circularDependencies: [] },
|
|
559
|
+
results: [],
|
|
560
|
+
errors: [],
|
|
561
|
+
summary: {
|
|
562
|
+
objectsProcessed: 0,
|
|
563
|
+
totalRecords: 0,
|
|
564
|
+
totalInserted: 0,
|
|
565
|
+
totalUpdated: 0,
|
|
566
|
+
totalSkipped: 0,
|
|
567
|
+
totalErrored: 0,
|
|
568
|
+
totalReferencesResolved: 0,
|
|
569
|
+
totalReferencesDeferred: 0,
|
|
570
|
+
circularDependencyCount: 0,
|
|
571
|
+
durationMs
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
buildResult(config, graph, results, errors, durationMs) {
|
|
576
|
+
const summary = {
|
|
577
|
+
objectsProcessed: results.length,
|
|
578
|
+
totalRecords: results.reduce((sum, r) => sum + r.total, 0),
|
|
579
|
+
totalInserted: results.reduce((sum, r) => sum + r.inserted, 0),
|
|
580
|
+
totalUpdated: results.reduce((sum, r) => sum + r.updated, 0),
|
|
581
|
+
totalSkipped: results.reduce((sum, r) => sum + r.skipped, 0),
|
|
582
|
+
totalErrored: results.reduce((sum, r) => sum + r.errored, 0),
|
|
583
|
+
totalReferencesResolved: results.reduce((sum, r) => sum + r.referencesResolved, 0),
|
|
584
|
+
totalReferencesDeferred: results.reduce((sum, r) => sum + r.referencesDeferred, 0),
|
|
585
|
+
circularDependencyCount: graph.circularDependencies.length,
|
|
586
|
+
durationMs
|
|
587
|
+
};
|
|
588
|
+
const hasErrors = errors.length > 0 || summary.totalErrored > 0;
|
|
589
|
+
return {
|
|
590
|
+
success: !hasErrors,
|
|
591
|
+
dryRun: config.dryRun,
|
|
592
|
+
dependencyGraph: graph,
|
|
593
|
+
results,
|
|
594
|
+
errors,
|
|
595
|
+
summary
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
// ==========================================================================
|
|
600
|
+
// Internal: Write Operations
|
|
601
|
+
// ==========================================================================
|
|
602
|
+
/**
|
|
603
|
+
* Seed writes always run as a privileged system context. This bypasses
|
|
604
|
+
* RBAC checks (so seeds can target system tables like `sys_*`) and
|
|
605
|
+
* disables the SecurityPlugin's auto-injection of `organization_id` /
|
|
606
|
+
* `owner_id` — seeds either declare those fields explicitly per
|
|
607
|
+
* record, or are intentionally cross-tenant / global.
|
|
608
|
+
*/
|
|
609
|
+
_SeedLoaderService.SEED_OPTIONS = { context: { isSystem: true } };
|
|
610
|
+
SeedLoaderService = _SeedLoaderService;
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
1
614
|
// src/registry.ts
|
|
2
615
|
import { ObjectSchema } from "@objectstack/spec/data";
|
|
3
616
|
import { readEnvWithDeprecation } from "@objectstack/types";
|
|
@@ -4215,12 +4828,16 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
4215
4828
|
item: result.item.body
|
|
4216
4829
|
});
|
|
4217
4830
|
await this.ensureObjectStorage(request.type, request.name);
|
|
4218
|
-
|
|
4831
|
+
const response = {
|
|
4219
4832
|
success: true,
|
|
4220
4833
|
version: result.version,
|
|
4221
4834
|
seq: result.seq,
|
|
4222
4835
|
message: `Published draft \u2014 type=${request.type}, name=${request.name} [seq=${result.seq}]`
|
|
4223
4836
|
};
|
|
4837
|
+
if (singularType === "seed" && !request._skipSeedApply) {
|
|
4838
|
+
response.seedApplied = await this.applySeedBodies([result.item.body], orgId);
|
|
4839
|
+
}
|
|
4840
|
+
return response;
|
|
4224
4841
|
} catch (err) {
|
|
4225
4842
|
if (err instanceof ConflictError2) {
|
|
4226
4843
|
const conflict = new Error(
|
|
@@ -4235,6 +4852,58 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
4235
4852
|
throw err;
|
|
4236
4853
|
}
|
|
4237
4854
|
}
|
|
4855
|
+
/**
|
|
4856
|
+
* Materialize published `seed` bodies into data rows via the SeedLoaderService
|
|
4857
|
+
* (externalId-keyed upsert, multi-pass for cross-seed references). Passing ALL
|
|
4858
|
+
* of a publish's seed bodies in ONE call lets a child seed reference a parent
|
|
4859
|
+
* seed's rows regardless of publish order. Best-effort: any failure is
|
|
4860
|
+
* returned, never thrown — publishing metadata must not be blocked by a data
|
|
4861
|
+
* problem, but the caller surfaces `seedApplied` so the failure is LOUD.
|
|
4862
|
+
*/
|
|
4863
|
+
async applySeedBodies(bodies, organizationId) {
|
|
4864
|
+
try {
|
|
4865
|
+
const seeds = bodies.filter(
|
|
4866
|
+
(b) => b && typeof b.object === "string" && Array.isArray(b.records)
|
|
4867
|
+
);
|
|
4868
|
+
if (seeds.length === 0) {
|
|
4869
|
+
return { success: false, inserted: 0, updated: 0, error: "seed apply: no readable seed bodies" };
|
|
4870
|
+
}
|
|
4871
|
+
const { SeedLoaderService: SeedLoaderService2 } = await Promise.resolve().then(() => (init_seed_loader(), seed_loader_exports));
|
|
4872
|
+
const { SeedLoaderRequestSchema } = await import("@objectstack/spec/data");
|
|
4873
|
+
const metadataAdapter = {
|
|
4874
|
+
getObject: async (name) => {
|
|
4875
|
+
const wrapper = await this.getMetaItem({
|
|
4876
|
+
type: "object",
|
|
4877
|
+
name,
|
|
4878
|
+
...organizationId ? { organizationId } : {}
|
|
4879
|
+
});
|
|
4880
|
+
return wrapper?.item ?? wrapper ?? null;
|
|
4881
|
+
}
|
|
4882
|
+
};
|
|
4883
|
+
const loader = new SeedLoaderService2(
|
|
4884
|
+
this.engine,
|
|
4885
|
+
metadataAdapter,
|
|
4886
|
+
console
|
|
4887
|
+
);
|
|
4888
|
+
const request = SeedLoaderRequestSchema.parse({
|
|
4889
|
+
seeds,
|
|
4890
|
+
config: {
|
|
4891
|
+
defaultMode: "upsert",
|
|
4892
|
+
multiPass: true,
|
|
4893
|
+
...organizationId ? { organizationId } : {}
|
|
4894
|
+
}
|
|
4895
|
+
});
|
|
4896
|
+
const r = await loader.load(request);
|
|
4897
|
+
return {
|
|
4898
|
+
success: r.success,
|
|
4899
|
+
inserted: r.summary.totalInserted,
|
|
4900
|
+
updated: r.summary.totalUpdated,
|
|
4901
|
+
...r.errors?.length ? { errors: r.errors } : {}
|
|
4902
|
+
};
|
|
4903
|
+
} catch (e) {
|
|
4904
|
+
return { success: false, inserted: 0, updated: 0, error: e?.message ?? "seed apply failed" };
|
|
4905
|
+
}
|
|
4906
|
+
}
|
|
4238
4907
|
/**
|
|
4239
4908
|
* List pending DRAFT metadata (ADR-0033) for the org, optionally narrowed
|
|
4240
4909
|
* by `packageId` and/or `type`. The list reads of `getMetaItems` only see
|
|
@@ -4268,14 +4937,25 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
4268
4937
|
const drafts = await repo.listDrafts({ packageId: request.packageId });
|
|
4269
4938
|
const published = [];
|
|
4270
4939
|
const failed = [];
|
|
4271
|
-
|
|
4940
|
+
const ordered = [
|
|
4941
|
+
...drafts.filter((d) => d.type !== "seed"),
|
|
4942
|
+
...drafts.filter((d) => d.type === "seed")
|
|
4943
|
+
];
|
|
4944
|
+
const seedBodies = [];
|
|
4945
|
+
for (const d of ordered) {
|
|
4272
4946
|
try {
|
|
4947
|
+
if (d.type === "seed") {
|
|
4948
|
+
const ref = { type: d.type, name: d.name, org: orgId ?? "env" };
|
|
4949
|
+
const draft = await repo.get(ref, { state: "draft" });
|
|
4950
|
+
if (draft?.body) seedBodies.push(draft.body);
|
|
4951
|
+
}
|
|
4273
4952
|
const r = await this.publishMetaItem({
|
|
4274
4953
|
type: d.type,
|
|
4275
4954
|
name: d.name,
|
|
4276
4955
|
...request.organizationId ? { organizationId: request.organizationId } : {},
|
|
4277
4956
|
...request.actor ? { actor: request.actor } : {},
|
|
4278
|
-
message: `publish app package '${request.packageId}'
|
|
4957
|
+
message: `publish app package '${request.packageId}'`,
|
|
4958
|
+
_skipSeedApply: true
|
|
4279
4959
|
});
|
|
4280
4960
|
published.push({ type: d.type, name: d.name, version: r.version });
|
|
4281
4961
|
} catch (e) {
|
|
@@ -4292,7 +4972,8 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
4292
4972
|
publishedCount: published.length,
|
|
4293
4973
|
failedCount: failed.length,
|
|
4294
4974
|
published,
|
|
4295
|
-
failed
|
|
4975
|
+
failed,
|
|
4976
|
+
...seedBodies.length > 0 ? { seedApplied: await this.applySeedBodies(seedBodies, orgId) } : {}
|
|
4296
4977
|
};
|
|
4297
4978
|
}
|
|
4298
4979
|
/**
|
|
@@ -9156,6 +9837,9 @@ function convertIntrospectedSchemaToObjects(introspectedSchema, options) {
|
|
|
9156
9837
|
}
|
|
9157
9838
|
return objects;
|
|
9158
9839
|
}
|
|
9840
|
+
|
|
9841
|
+
// src/index.ts
|
|
9842
|
+
init_seed_loader();
|
|
9159
9843
|
export {
|
|
9160
9844
|
DEFAULT_EXTENDER_PRIORITY,
|
|
9161
9845
|
DEFAULT_OWNER_PRIORITY,
|
|
@@ -9170,6 +9854,7 @@ export {
|
|
|
9170
9854
|
SECRET_REF_PREFIX,
|
|
9171
9855
|
SchemaRegistry,
|
|
9172
9856
|
ScopedContext,
|
|
9857
|
+
SeedLoaderService,
|
|
9173
9858
|
SysMetadataRepository,
|
|
9174
9859
|
ValidationError,
|
|
9175
9860
|
applyInMemoryAggregation,
|