@nocobase/flow-engine 2.0.22 → 2.1.0-alpha.10

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.
@@ -65,6 +65,7 @@ var import_utils = require("./utils");
65
65
  var import_exceptions = require("./utils/exceptions");
66
66
  var import_useFlowStep = require("./hooks/useFlowStep");
67
67
  var import_views = require("./views");
68
+ var import_lazy_helper = require("./lazy-helper");
68
69
  var _forceEnabled, _emitter;
69
70
  const Panel = import_antd.Collapse.Panel;
70
71
  const _FlowSettings = class _FlowSettings {
@@ -73,10 +74,12 @@ const _FlowSettings = class _FlowSettings {
73
74
  __publicField(this, "scopes", {});
74
75
  __publicField(this, "antdComponentsLoaded", false);
75
76
  __publicField(this, "enabled");
77
+ __publicField(this, "engine");
76
78
  __privateAdd(this, _forceEnabled, false);
77
79
  // 强制启用状态,主要用于设计模式下的强制启用
78
80
  __publicField(this, "toolbarItems", []);
79
81
  __privateAdd(this, _emitter, new import_emitter.Emitter());
82
+ this.engine = engine;
80
83
  this.enabled = false;
81
84
  engine.context.defineProperty("flowSettingsEnabled", {
82
85
  get: /* @__PURE__ */ __name(() => this.enabled, "get"),
@@ -215,6 +218,29 @@ const _FlowSettings = class _FlowSettings {
215
218
  this.components[name] = components[name];
216
219
  });
217
220
  }
221
+ registerComponentLoaders(loaders) {
222
+ Object.entries(loaders).forEach(([name, loader]) => {
223
+ if (this.components[name]) {
224
+ console.warn(`FlowSettings: Component with name '${name}' is already registered and will be overwritten.`);
225
+ }
226
+ this.components[name] = (0, import_lazy_helper.lazy)(async () => {
227
+ const loaded = await loader();
228
+ if (typeof loaded === "function") {
229
+ return { default: loaded };
230
+ }
231
+ if ((loaded == null ? void 0 : loaded.default) && typeof loaded.default === "function") {
232
+ return { default: loaded.default };
233
+ }
234
+ const namedComponent = loaded == null ? void 0 : loaded[name];
235
+ if (typeof namedComponent === "function") {
236
+ return { default: namedComponent };
237
+ }
238
+ throw new Error(
239
+ `FlowSettings: component loader for '${name}' must resolve to a React component or a module exporting it.`
240
+ );
241
+ });
242
+ });
243
+ }
218
244
  /**
219
245
  * 添加作用域到 FlowSettings 的作用域注册表中。
220
246
  * 这些作用域可以在 flow step 的 uiSchema 中使用。
@@ -234,27 +260,29 @@ const _FlowSettings = class _FlowSettings {
234
260
  /**
235
261
  * 启用流程设置组件的显示
236
262
  * @example
237
- * flowSettings.enable();
263
+ * await flowSettings.enable();
238
264
  */
239
- enable() {
265
+ async enable() {
266
+ await this.engine.preloadModelLoaders();
240
267
  this.enabled = true;
241
268
  }
242
- forceEnable() {
269
+ async forceEnable() {
270
+ await this.engine.preloadModelLoaders();
243
271
  __privateSet(this, _forceEnabled, true);
244
272
  this.enabled = true;
245
273
  }
246
274
  /**
247
275
  * 禁用流程设置组件的显示
248
276
  * @example
249
- * flowSettings.disable();
277
+ * await flowSettings.disable();
250
278
  */
251
- disable() {
279
+ async disable() {
252
280
  if (__privateGet(this, _forceEnabled)) {
253
281
  return;
254
282
  }
255
283
  this.enabled = false;
256
284
  }
257
- forceDisable() {
285
+ async forceDisable() {
258
286
  __privateSet(this, _forceEnabled, false);
259
287
  this.enabled = false;
260
288
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+ type LazyComponentType<M extends Record<string, any>, K extends keyof M> = {
10
+ [P in K]: M[P];
11
+ };
12
+ export declare function lazy<M extends Record<'default', any>>(factory: () => Promise<M>): M['default'];
13
+ export declare function lazy<M extends Record<string, any>, K extends keyof M = keyof M>(factory: () => Promise<M>, ...componentNames: K[]): LazyComponentType<M, K>;
14
+ export {};
@@ -0,0 +1,71 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ var __create = Object.create;
11
+ var __defProp = Object.defineProperty;
12
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
13
+ var __getOwnPropNames = Object.getOwnPropertyNames;
14
+ var __getProtoOf = Object.getPrototypeOf;
15
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
16
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
17
+ var __export = (target, all) => {
18
+ for (var name in all)
19
+ __defProp(target, name, { get: all[name], enumerable: true });
20
+ };
21
+ var __copyProps = (to, from, except, desc) => {
22
+ if (from && typeof from === "object" || typeof from === "function") {
23
+ for (let key of __getOwnPropNames(from))
24
+ if (!__hasOwnProp.call(to, key) && key !== except)
25
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
26
+ }
27
+ return to;
28
+ };
29
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
30
+ // If the importer is in node compatibility mode or this is not an ESM
31
+ // file that has been converted to a CommonJS file using a Babel-
32
+ // compatible transform (i.e. "__esModule" has not been set), then set
33
+ // "default" to the CommonJS "module.exports" for node compatibility.
34
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
35
+ mod
36
+ ));
37
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
38
+ var lazy_helper_exports = {};
39
+ __export(lazy_helper_exports, {
40
+ lazy: () => lazy
41
+ });
42
+ module.exports = __toCommonJS(lazy_helper_exports);
43
+ var import_react = __toESM(require("react"));
44
+ function lazy(factory, ...componentNames) {
45
+ if (componentNames.length === 0) {
46
+ const LazyComponent = (0, import_react.lazy)(
47
+ () => factory().then((module2) => ({
48
+ default: module2.default
49
+ }))
50
+ );
51
+ const Component = /* @__PURE__ */ __name((props) => /* @__PURE__ */ import_react.default.createElement(import_react.default.Suspense, { fallback: null }, /* @__PURE__ */ import_react.default.createElement(LazyComponent, { ...props })), "Component");
52
+ return Component;
53
+ }
54
+ return componentNames.reduce(
55
+ (acc, name) => {
56
+ const LazyComponent = (0, import_react.lazy)(
57
+ () => factory().then((module2) => ({
58
+ default: module2[name]
59
+ }))
60
+ );
61
+ acc[name] = (props) => /* @__PURE__ */ import_react.default.createElement(import_react.default.Suspense, { fallback: null }, /* @__PURE__ */ import_react.default.createElement(LazyComponent, { ...props }));
62
+ return acc;
63
+ },
64
+ {}
65
+ );
66
+ }
67
+ __name(lazy, "lazy");
68
+ // Annotate the CommonJS export names for ESM import in node:
69
+ 0 && (module.exports = {
70
+ lazy
71
+ });
@@ -56,13 +56,11 @@ var import_reactive = require("@formily/reactive");
56
56
  var import_lodash = __toESM(require("lodash"));
57
57
  var import_react = __toESM(require("react"));
58
58
  var import_secure = require("uid/secure");
59
- var import_StepRequiredSettingsDialog = require("../components/settings/wrappers/contextual/StepRequiredSettingsDialog");
60
- var import_StepSettingsDialog = require("../components/settings/wrappers/contextual/StepSettingsDialog");
61
59
  var import_emitter = require("../emitter");
62
60
  var import_InstanceFlowRegistry = require("../flow-registry/InstanceFlowRegistry");
63
61
  var import_flowContext = require("../flowContext");
64
62
  var import_utils = require("../utils");
65
- var import_lib = require("antd/lib");
63
+ var import_antd = require("antd");
66
64
  var import_ModelActionRegistry = require("../action-registry/ModelActionRegistry");
67
65
  var import_utils2 = require("../components/subModel/utils");
68
66
  var import_ModelEventRegistry = require("../event-registry/ModelEventRegistry");
@@ -75,6 +73,16 @@ const classEventRegistries = /* @__PURE__ */ new WeakMap();
75
73
  const modelMetas = /* @__PURE__ */ new WeakMap();
76
74
  const modelGlobalRegistries = /* @__PURE__ */ new WeakMap();
77
75
  const classMenuExtensions = /* @__PURE__ */ new WeakMap();
76
+ async function loadOpenStepSettingsDialog() {
77
+ const mod = await import("../components/settings/wrappers/contextual/StepSettingsDialog");
78
+ return mod.openStepSettingsDialog;
79
+ }
80
+ __name(loadOpenStepSettingsDialog, "loadOpenStepSettingsDialog");
81
+ async function loadOpenRequiredParamsStepFormDialog() {
82
+ const mod = await import("../components/settings/wrappers/contextual/StepRequiredSettingsDialog");
83
+ return mod.openRequiredParamsStepFormDialog;
84
+ }
85
+ __name(loadOpenRequiredParamsStepFormDialog, "loadOpenRequiredParamsStepFormDialog");
78
86
  var ModelRenderMode = /* @__PURE__ */ ((ModelRenderMode2) => {
79
87
  ModelRenderMode2["ReactElement"] = "reactElement";
80
88
  ModelRenderMode2["RenderFunction"] = "renderFunction";
@@ -1058,7 +1066,7 @@ const _FlowModel = class _FlowModel {
1058
1066
  * @param {string} stepKey 步骤的唯一标识符
1059
1067
  * @returns {void}
1060
1068
  */
1061
- openStepSettingsDialog(flowKey, stepKey) {
1069
+ async openStepSettingsDialog(flowKey, stepKey) {
1062
1070
  var _a;
1063
1071
  const flow = this.getFlow(flowKey);
1064
1072
  const step = (_a = flow == null ? void 0 : flow.steps) == null ? void 0 : _a[stepKey];
@@ -1069,7 +1077,8 @@ const _FlowModel = class _FlowModel {
1069
1077
  const ctx = new import_flowContext.FlowRuntimeContext(this, flowKey, "settings");
1070
1078
  (0, import_utils.setupRuntimeContextSteps)(ctx, flow.steps, this, flowKey);
1071
1079
  ctx.defineProperty("currentStep", { value: step });
1072
- return (0, import_StepSettingsDialog.openStepSettingsDialog)({
1080
+ const openStepSettingsDialog = await loadOpenStepSettingsDialog();
1081
+ return openStepSettingsDialog({
1073
1082
  model: this,
1074
1083
  flowKey,
1075
1084
  stepKey,
@@ -1084,7 +1093,8 @@ const _FlowModel = class _FlowModel {
1084
1093
  * @returns {Promise<any>} 返回表单提交的值
1085
1094
  */
1086
1095
  async configureRequiredSteps(dialogWidth, dialogTitle) {
1087
- return (0, import_StepRequiredSettingsDialog.openRequiredParamsStepFormDialog)({
1096
+ const openRequiredParamsStepFormDialog = await loadOpenRequiredParamsStepFormDialog();
1097
+ return openRequiredParamsStepFormDialog({
1088
1098
  model: this,
1089
1099
  dialogWidth,
1090
1100
  dialogTitle
@@ -1303,7 +1313,7 @@ const _ErrorFlowModel = class _ErrorFlowModel extends FlowModel {
1303
1313
  this.errorMessage = msg;
1304
1314
  }
1305
1315
  render() {
1306
- return /* @__PURE__ */ import_react.default.createElement(import_lib.Typography.Text, { type: "danger" }, this.errorMessage);
1316
+ return /* @__PURE__ */ import_react.default.createElement(import_antd.Typography.Text, { type: "danger" }, this.errorMessage);
1307
1317
  }
1308
1318
  };
1309
1319
  __name(_ErrorFlowModel, "ErrorFlowModel");
package/lib/types.d.ts CHANGED
@@ -303,6 +303,41 @@ export interface CreateModelOptions {
303
303
  delegateToParent?: boolean;
304
304
  [key: string]: any;
305
305
  }
306
+ /**
307
+ * FlowModel loader result.
308
+ * Supports returning the model constructor directly, a default export, or a module object containing the named export.
309
+ */
310
+ export type FlowModelLoaderResult = ModelConstructor | {
311
+ default?: ModelConstructor;
312
+ [key: string]: unknown;
313
+ } | Record<string, unknown>;
314
+ /**
315
+ * FlowModel loader function.
316
+ */
317
+ export type FlowModelLoader = () => Promise<FlowModelLoaderResult>;
318
+ /**
319
+ * FlowModel loader entry.
320
+ * Future contribution-style fields are intentionally kept as commented placeholders in phase A
321
+ * and are not consumed by current runtime logic.
322
+ */
323
+ export interface FlowModelLoaderEntry {
324
+ loader: FlowModelLoader;
325
+ }
326
+ /**
327
+ * FlowModel loader entry map.
328
+ */
329
+ export type FlowModelLoaderMap = Record<string, FlowModelLoaderEntry>;
330
+ /**
331
+ * Batch ensure result.
332
+ */
333
+ export interface EnsureBatchResult {
334
+ requested: string[];
335
+ loaded: string[];
336
+ failed: Array<{
337
+ name: string;
338
+ error?: unknown;
339
+ }>;
340
+ }
306
341
  export interface IFlowModelRepository<T extends FlowModel = FlowModel> {
307
342
  findOne(query: Record<string, any>): Promise<Record<string, any> | null>;
308
343
  save(model: T, options?: {
@@ -527,8 +527,8 @@ function extractUsedCtxLibKeys(code) {
527
527
  }
528
528
  __name(extractUsedCtxLibKeys, "extractUsedCtxLibKeys");
529
529
  function injectEnsureLibsPreamble(code) {
530
- if (!CTX_LIBS_MARKER_RE.test(code)) return code;
531
530
  if (ENSURE_LIBS_MARKER_RE.test(code)) return code;
531
+ if (!CTX_LIBS_MARKER_RE.test(code)) return code;
532
532
  const keys = extractUsedCtxLibKeys(code);
533
533
  if (!keys.length) return code;
534
534
  return `/* __runjs_ensure_libs */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.0.22",
3
+ "version": "2.1.0-alpha.10",
4
4
  "private": false,
5
5
  "description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
6
6
  "main": "lib/index.js",
@@ -8,8 +8,8 @@
8
8
  "dependencies": {
9
9
  "@formily/antd-v5": "1.x",
10
10
  "@formily/reactive": "2.x",
11
- "@nocobase/sdk": "2.0.22",
12
- "@nocobase/shared": "2.0.22",
11
+ "@nocobase/sdk": "2.1.0-alpha.10",
12
+ "@nocobase/shared": "2.1.0-alpha.10",
13
13
  "ahooks": "^3.7.2",
14
14
  "dayjs": "^1.11.9",
15
15
  "dompurify": "^3.0.2",
@@ -36,5 +36,5 @@
36
36
  ],
37
37
  "author": "NocoBase Team",
38
38
  "license": "Apache-2.0",
39
- "gitHead": "7b703580c4bb60f1e5e44c8fd685141bb8b3a47c"
39
+ "gitHead": "ce790d46c0a5768ca9618c7d0d77ab8300de75c8"
40
40
  }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
11
+ import { FlowEngine } from '../flowEngine';
12
+ import { ErrorFlowModel, FlowModel } from '../models';
13
+ import type { IFlowModelRepository } from '../types';
14
+
15
+ class MockFlowModelRepository implements IFlowModelRepository {
16
+ findOneResult: any = null;
17
+ save = vi.fn(async (model: FlowModel) => ({ success: true, uid: model.uid }));
18
+
19
+ async findOne() {
20
+ return this.findOneResult ? JSON.parse(JSON.stringify(this.findOneResult)) : null;
21
+ }
22
+
23
+ async destroy() {
24
+ return true;
25
+ }
26
+
27
+ async move() {}
28
+
29
+ async duplicate() {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ describe('FlowEngine model loaders', () => {
35
+ let engine: FlowEngine;
36
+ let repo: MockFlowModelRepository;
37
+
38
+ beforeEach(() => {
39
+ engine = new FlowEngine();
40
+ repo = new MockFlowModelRepository();
41
+ engine.setModelRepository(repo);
42
+ });
43
+
44
+ it('resolves explicit and meta-default model trees before synchronous creation', async () => {
45
+ class ParentModel extends FlowModel {}
46
+ class ChildModel extends FlowModel {}
47
+ class DefaultChildModel extends FlowModel {}
48
+
49
+ ParentModel.define({
50
+ createModelOptions: {
51
+ subModels: {
52
+ defaultChild: {
53
+ use: 'DefaultChildModel',
54
+ },
55
+ },
56
+ },
57
+ });
58
+
59
+ const parentLoader = vi.fn(async () => ({ ParentModel }));
60
+ const childLoader = vi.fn(async () => ({ ChildModel }));
61
+ const defaultChildLoader = vi.fn(async () => ({ DefaultChildModel }));
62
+
63
+ engine.registerModelLoaders({
64
+ ParentModel: { loader: parentLoader },
65
+ ChildModel: { loader: childLoader },
66
+ DefaultChildModel: { loader: defaultChildLoader },
67
+ });
68
+
69
+ const model = await engine.loadOrCreateModel({
70
+ uid: 'parent-model',
71
+ use: 'ParentModel',
72
+ subModels: {
73
+ child: {
74
+ use: 'ChildModel',
75
+ },
76
+ },
77
+ });
78
+
79
+ expect(model).toBeInstanceOf(ParentModel);
80
+ expect(model?.subModels.child).toBeInstanceOf(ChildModel);
81
+ expect(model?.subModels.defaultChild).toBeInstanceOf(DefaultChildModel);
82
+ expect(parentLoader).toHaveBeenCalledTimes(1);
83
+ expect(childLoader).toHaveBeenCalledTimes(1);
84
+ expect(defaultChildLoader).toHaveBeenCalledTimes(1);
85
+ });
86
+
87
+ it('resolves repository-loaded model trees before loadModel creates instances', async () => {
88
+ class RepoRootModel extends FlowModel {}
89
+ class RepoChildModel extends FlowModel {}
90
+
91
+ const rootLoader = vi.fn(async () => ({ RepoRootModel }));
92
+ const childLoader = vi.fn(async () => ({ RepoChildModel }));
93
+
94
+ engine.registerModelLoaders({
95
+ RepoRootModel: { loader: rootLoader },
96
+ RepoChildModel: { loader: childLoader },
97
+ });
98
+
99
+ repo.findOneResult = {
100
+ uid: 'repo-root',
101
+ use: 'RepoRootModel',
102
+ subModels: {
103
+ child: {
104
+ use: 'RepoChildModel',
105
+ },
106
+ },
107
+ };
108
+
109
+ const model = await engine.loadModel({ uid: 'repo-root' });
110
+
111
+ expect(model).toBeInstanceOf(RepoRootModel);
112
+ expect(model?.subModels.child).toBeInstanceOf(RepoChildModel);
113
+ expect(rootLoader).toHaveBeenCalledTimes(1);
114
+ expect(childLoader).toHaveBeenCalledTimes(1);
115
+ });
116
+
117
+ it('supports async model creation and async getters', async () => {
118
+ class AsyncRootModel extends FlowModel {}
119
+ class AsyncChildModel extends FlowModel {}
120
+
121
+ const rootLoader = vi.fn(async () => ({ AsyncRootModel }));
122
+ const childLoader = vi.fn(async () => ({ AsyncChildModel }));
123
+
124
+ engine.registerModelLoaders({
125
+ AsyncRootModel: { loader: rootLoader },
126
+ AsyncChildModel: { loader: childLoader },
127
+ });
128
+
129
+ const rootClass = await engine.getModelClassAsync('AsyncRootModel');
130
+ const classes = await engine.getModelClassesAsync();
131
+ const model = await engine.createModelAsync({
132
+ uid: 'async-root',
133
+ use: 'AsyncRootModel',
134
+ subModels: {
135
+ child: {
136
+ use: 'AsyncChildModel',
137
+ },
138
+ },
139
+ });
140
+
141
+ expect(rootClass).toBe(AsyncRootModel);
142
+ expect(classes.get('AsyncRootModel')).toBe(AsyncRootModel);
143
+ expect(classes.get('AsyncChildModel')).toBe(AsyncChildModel);
144
+ expect(model).toBeInstanceOf(AsyncRootModel);
145
+ expect(model.subModels.child).toBeInstanceOf(AsyncChildModel);
146
+ expect(rootLoader).toHaveBeenCalledTimes(1);
147
+ expect(childLoader).toHaveBeenCalledTimes(1);
148
+ });
149
+
150
+ it('createModelAsync degrades unresolved loader failures to ErrorFlowModel', async () => {
151
+ const invalidLoader = vi.fn(async () => ({ notAModel: {} }));
152
+
153
+ engine.registerModelLoaders({
154
+ BrokenRootModel: { loader: invalidLoader as any },
155
+ });
156
+
157
+ const model = await engine.createModelAsync({
158
+ uid: 'broken-root',
159
+ use: 'BrokenRootModel',
160
+ });
161
+
162
+ expect(model).toBeInstanceOf(ErrorFlowModel);
163
+ expect(invalidLoader).toHaveBeenCalledTimes(1);
164
+ });
165
+
166
+ it('keeps loader resolution idempotent across resolveModelTree and flow settings preload', async () => {
167
+ class RuntimeResolvedModel extends FlowModel {}
168
+ class DesignResolvedModel extends FlowModel {}
169
+
170
+ const runtimeLoader = vi.fn(async () => ({ RuntimeResolvedModel }));
171
+ const designLoader = vi.fn(async () => ({ DesignResolvedModel }));
172
+
173
+ engine.registerModelLoaders({
174
+ RuntimeResolvedModel: { loader: runtimeLoader },
175
+ DesignResolvedModel: { loader: designLoader },
176
+ });
177
+
178
+ await engine.resolveModelTree({
179
+ use: 'RuntimeResolvedModel',
180
+ });
181
+
182
+ const firstPreload = await engine.preloadModelLoaders();
183
+ const secondPreload = await engine.preloadModelLoaders();
184
+
185
+ expect(runtimeLoader).toHaveBeenCalledTimes(1);
186
+ expect(designLoader).toHaveBeenCalledTimes(1);
187
+ expect(firstPreload.loaded).toContain('DesignResolvedModel');
188
+ expect(firstPreload.loaded).not.toContain('RuntimeResolvedModel');
189
+ expect(secondPreload.loaded).toHaveLength(0);
190
+ expect(secondPreload.failed).toHaveLength(0);
191
+ });
192
+
193
+ it('picks up newly registered loaders after preload has already completed', async () => {
194
+ class FirstModel extends FlowModel {}
195
+ class SecondModel extends FlowModel {}
196
+
197
+ const firstLoader = vi.fn(async () => ({ FirstModel }));
198
+ const secondLoader = vi.fn(async () => ({ SecondModel }));
199
+
200
+ engine.registerModelLoaders({
201
+ FirstModel: { loader: firstLoader },
202
+ });
203
+
204
+ await engine.preloadModelLoaders();
205
+ expect(firstLoader).toHaveBeenCalledTimes(1);
206
+ expect(engine.getModelClass('FirstModel')).toBe(FirstModel);
207
+
208
+ engine.registerModelLoaders({
209
+ SecondModel: { loader: secondLoader },
210
+ });
211
+
212
+ const result = await engine.preloadModelLoaders();
213
+
214
+ expect(secondLoader).toHaveBeenCalledTimes(1);
215
+ expect(result.loaded).toContain('SecondModel');
216
+ expect(engine.getModelClass('SecondModel')).toBe(SecondModel);
217
+ });
218
+
219
+ it('degrades unresolved loader failures to ErrorFlowModel instead of crashing runtime creation', async () => {
220
+ class ParentModel extends FlowModel {}
221
+
222
+ const parentLoader = vi.fn(async () => ({ ParentModel }));
223
+ const invalidChildLoader = vi.fn(async () => ({ notAModel: {} }));
224
+
225
+ engine.registerModelLoaders({
226
+ ParentModel: { loader: parentLoader },
227
+ BrokenChildModel: { loader: invalidChildLoader as any },
228
+ });
229
+
230
+ const model = await engine.loadOrCreateModel({
231
+ uid: 'parent-with-broken-child',
232
+ use: 'ParentModel',
233
+ subModels: {
234
+ child: {
235
+ use: 'BrokenChildModel',
236
+ },
237
+ },
238
+ });
239
+
240
+ expect(model).toBeInstanceOf(ParentModel);
241
+ expect(model?.subModels.child).toBeInstanceOf(ErrorFlowModel);
242
+ expect(parentLoader).toHaveBeenCalledTimes(1);
243
+ expect(invalidChildLoader).toHaveBeenCalledTimes(1);
244
+ });
245
+ });