bunsane 0.3.2 → 0.4.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/CHANGELOG.md +445 -370
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +85 -1020
- package/core/EntityHookManager.ts +52 -754
- package/core/Logger.ts +10 -0
- package/core/RequestContext.ts +94 -85
- package/core/RequestLoaders.ts +98 -5
- package/core/SchedulerManager.ts +28 -600
- package/core/app/cors.ts +2 -11
- package/core/app/preparedStatementWarmup.ts +9 -49
- package/core/app/requestRouter.ts +9 -8
- package/core/app/restRegistry.ts +8 -0
- package/core/archetype/fieldResolvers.ts +85 -40
- package/core/archetype/relationLoader.ts +135 -92
- package/core/cache/CacheManager.ts +91 -302
- package/core/cache/CompressionUtils.ts +34 -3
- package/core/cache/MemoryCache.ts +40 -37
- package/core/cache/RedisCache.ts +4 -4
- package/core/cache/health.ts +30 -0
- package/core/cache/invalidation.ts +96 -0
- package/core/cache/strategies/writeInvalidate.ts +111 -0
- package/core/cache/strategies/writeThrough.ts +233 -0
- package/core/components/BaseComponent.ts +16 -8
- package/core/components/ComponentRegistry.ts +28 -0
- package/core/decorators/IndexedField.ts +1 -1
- package/core/entity/cacheStrategies.ts +97 -0
- package/core/entity/componentAccess.ts +364 -0
- package/core/entity/finders.ts +202 -0
- package/core/entity/pendingOps.ts +72 -0
- package/core/entity/saveEntity.ts +377 -0
- package/core/hooks/dispatcher.ts +439 -0
- package/core/hooks/guards.ts +155 -0
- package/core/hooks/registry.ts +247 -0
- package/core/metadata/definitions/Component.ts +1 -1
- package/core/metadata/index.ts +15 -4
- package/core/middleware/RateLimit.ts +102 -105
- package/core/middleware/RequestId.ts +2 -9
- package/core/middleware/SecurityHeaders.ts +2 -11
- package/core/middleware/headers.ts +28 -0
- package/core/remote/OutboxWorker.ts +213 -183
- package/core/remote/RemoteManager.ts +401 -400
- package/core/remote/types.ts +153 -151
- package/core/requestScope.ts +34 -0
- package/core/scheduler/cronEvaluator.ts +174 -0
- package/core/scheduler/lifecycleHooks.ts +21 -0
- package/core/scheduler/lockCoordinator.ts +27 -0
- package/core/scheduler/metrics.ts +14 -0
- package/core/scheduler/taskRunner.ts +420 -0
- package/database/DatabaseHelper.ts +128 -101
- package/database/IndexingStrategy.ts +72 -2
- package/database/PreparedStatementCache.ts +8 -2
- package/database/cancellable.ts +35 -22
- package/database/index.ts +15 -3
- package/database/instrumentedDb.ts +141 -141
- package/endpoints/archetypes.ts +2 -8
- package/endpoints/tables.ts +6 -1
- package/gql/index.ts +1 -1
- package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
- package/package.json +22 -1
- package/query/CTENode.ts +5 -3
- package/query/ComponentInclusionNode.ts +240 -13
- package/query/OrNode.ts +6 -5
- package/query/Query.ts +157 -46
- package/query/QueryContext.ts +6 -0
- package/query/QueryDAG.ts +7 -2
- package/query/membershipSource.ts +66 -0
- package/storage/LocalStorageProvider.ts +8 -3
- package/studio/dist/assets/index-BMZ67Npg.js +254 -0
- package/studio/dist/assets/index-BpbuYz9g.css +1 -0
- package/studio/{index.html → dist/index.html} +3 -2
- package/swagger/generator.ts +11 -1
- package/upload/UploadManager.ts +8 -6
- package/utils/uuid.ts +40 -10
- package/.claude/scheduled_tasks.lock +0 -1
- package/.claude/settings.local.json +0 -47
- package/.prettierrc +0 -4
- package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
- package/.serena/memories/architecture.md +0 -154
- package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
- package/.serena/memories/code_style_and_conventions.md +0 -76
- package/.serena/memories/project_overview.md +0 -43
- package/.serena/memories/schema-dsl-plan.md +0 -107
- package/.serena/memories/suggested_commands.md +0 -80
- package/.serena/memories/typescript-compilation-status.md +0 -54
- package/.serena/project.yml +0 -114
- package/BunSane.jpg +0 -0
- package/CLAUDE.md +0 -198
- package/TODO.md +0 -2
- package/bun.lock +0 -302
- package/bunfig.toml +0 -10
- package/docs/RFC_APP_REFACTOR.md +0 -248
- package/docs/RFC_REFACTOR_TARGETS.md +0 -251
- package/docs/SCALABILITY_PLAN.md +0 -175
- package/studio/bun.lock +0 -482
- package/studio/package.json +0 -39
- package/studio/postcss.config.js +0 -6
- package/studio/src/components/DataTable.tsx +0 -211
- package/studio/src/components/Layout.tsx +0 -13
- package/studio/src/components/PageContainer.tsx +0 -9
- package/studio/src/components/PageHeader.tsx +0 -13
- package/studio/src/components/SearchBar.tsx +0 -57
- package/studio/src/components/Sidebar.tsx +0 -294
- package/studio/src/components/ui/button.tsx +0 -56
- package/studio/src/components/ui/checkbox.tsx +0 -26
- package/studio/src/components/ui/input.tsx +0 -25
- package/studio/src/hooks/useDataTable.ts +0 -131
- package/studio/src/index.css +0 -36
- package/studio/src/lib/api.ts +0 -186
- package/studio/src/lib/utils.ts +0 -13
- package/studio/src/main.tsx +0 -17
- package/studio/src/pages/ArcheType.tsx +0 -239
- package/studio/src/pages/Components.tsx +0 -124
- package/studio/src/pages/EntityInspector.tsx +0 -302
- package/studio/src/pages/QueryRunner.tsx +0 -246
- package/studio/src/pages/Table.tsx +0 -94
- package/studio/src/pages/Welcome.tsx +0 -241
- package/studio/src/routes.tsx +0 -45
- package/studio/src/store/archeTypeSettings.ts +0 -30
- package/studio/src/store/studio.ts +0 -65
- package/studio/src/utils/columnHelpers.tsx +0 -114
- package/studio/studio-instructions.md +0 -81
- package/studio/tailwind.config.js +0 -77
- package/studio/utils.ts +0 -54
- package/studio/vite.config.js +0 -19
- package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
- package/tests/benchmark/bunfig.toml +0 -9
- package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
- package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
- package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
- package/tests/benchmark/fixtures/index.ts +0 -6
- package/tests/benchmark/index.ts +0 -22
- package/tests/benchmark/noop-preload.ts +0 -3
- package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
- package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
- package/tests/benchmark/runners/index.ts +0 -4
- package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
- package/tests/benchmark/scripts/generate-db.ts +0 -344
- package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
- package/tests/e2e/http.test.ts +0 -130
- package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
- package/tests/fixtures/components/TestOrder.ts +0 -23
- package/tests/fixtures/components/TestProduct.ts +0 -23
- package/tests/fixtures/components/TestUser.ts +0 -20
- package/tests/fixtures/components/index.ts +0 -6
- package/tests/graphql/SchemaGeneration.test.ts +0 -90
- package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
- package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
- package/tests/helpers/MockRedisClient.ts +0 -113
- package/tests/helpers/MockRedisStreamServer.ts +0 -448
- package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
- package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
- package/tests/integration/entity/Entity.persistence.test.ts +0 -333
- package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
- package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
- package/tests/integration/query/Query.abort.test.ts +0 -66
- package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
- package/tests/integration/query/Query.edgeCases.test.ts +0 -595
- package/tests/integration/query/Query.exec.test.ts +0 -576
- package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
- package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
- package/tests/integration/remote/dlq.test.ts +0 -175
- package/tests/integration/remote/event-dispatch.test.ts +0 -114
- package/tests/integration/remote/outbox.test.ts +0 -130
- package/tests/integration/remote/rpc.test.ts +0 -177
- package/tests/pglite-setup.ts +0 -62
- package/tests/setup.ts +0 -164
- package/tests/stress/BenchmarkRunner.ts +0 -203
- package/tests/stress/DataSeeder.ts +0 -190
- package/tests/stress/StressTestReporter.ts +0 -229
- package/tests/stress/cursor-perf-test.ts +0 -171
- package/tests/stress/fixtures/RealisticComponents.ts +0 -235
- package/tests/stress/fixtures/StressTestComponents.ts +0 -58
- package/tests/stress/index.ts +0 -7
- package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
- package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
- package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
- package/tests/unit/BatchLoader.test.ts +0 -196
- package/tests/unit/archetype/ArcheType.test.ts +0 -107
- package/tests/unit/cache/CacheManager.test.ts +0 -498
- package/tests/unit/cache/MemoryCache.test.ts +0 -260
- package/tests/unit/cache/RedisCache.test.ts +0 -411
- package/tests/unit/database/cancellable.test.ts +0 -81
- package/tests/unit/database/instrumentedDb.test.ts +0 -160
- package/tests/unit/entity/Entity.components.test.ts +0 -317
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
- package/tests/unit/entity/Entity.reload.test.ts +0 -63
- package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
- package/tests/unit/entity/Entity.test.ts +0 -345
- package/tests/unit/gql/depthLimit.test.ts +0 -203
- package/tests/unit/gql/operationMiddleware.test.ts +0 -293
- package/tests/unit/health/Health.test.ts +0 -129
- package/tests/unit/middleware/AccessLog.test.ts +0 -37
- package/tests/unit/middleware/Middleware.test.ts +0 -98
- package/tests/unit/middleware/RequestId.test.ts +0 -54
- package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
- package/tests/unit/query/FilterBuilder.test.ts +0 -111
- package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
- package/tests/unit/query/Query.emptyString.test.ts +0 -69
- package/tests/unit/query/Query.test.ts +0 -310
- package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
- package/tests/unit/remote/RemoteError.test.ts +0 -55
- package/tests/unit/remote/decorators.test.ts +0 -195
- package/tests/unit/remote/metrics.test.ts +0 -115
- package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
- package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
- package/tests/unit/schema/schema-integration.test.ts +0 -426
- package/tests/unit/schema/schema.test.ts +0 -580
- package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
- package/tests/unit/upload/RestUpload.test.ts +0 -267
- package/tests/unit/validateEnv.test.ts +0 -82
- package/tests/utils/entity-tracker.ts +0 -57
- package/tests/utils/index.ts +0 -13
- package/tests/utils/test-context.ts +0 -149
|
@@ -52,11 +52,16 @@ export class BaseComponent {
|
|
|
52
52
|
const data: Record<string, any> = {};
|
|
53
53
|
const storage = getMetadataStorage();
|
|
54
54
|
const props = storage.componentProperties.get(this._typeId);
|
|
55
|
-
|
|
55
|
+
if (!props) return data;
|
|
56
|
+
// Iterate the property metadata directly — avoids the prior O(n²)
|
|
57
|
+
// pattern (properties().forEach + props.find per property) and the
|
|
58
|
+
// redundant second metadata lookup inside properties(). Hot write path:
|
|
59
|
+
// runs for every dirty component on every save.
|
|
60
|
+
for (const propMeta of props) {
|
|
61
|
+
const prop = propMeta.propertyKey;
|
|
56
62
|
let value = (this as any)[prop];
|
|
57
|
-
const propMeta = props?.find(p => p.propertyKey === prop);
|
|
58
63
|
if (value !== null && value !== undefined) {
|
|
59
|
-
if (propMeta
|
|
64
|
+
if (propMeta.propertyType === Date) {
|
|
60
65
|
if (!(value instanceof Date)) {
|
|
61
66
|
throw new Error(`Type mismatch for property '${prop}' on component '${this._comp_name}': expected Date, got ${typeof value}`);
|
|
62
67
|
}
|
|
@@ -64,17 +69,21 @@ export class BaseComponent {
|
|
|
64
69
|
throw new Error(`Invalid Date for property '${prop}' on component '${this._comp_name}'`);
|
|
65
70
|
}
|
|
66
71
|
value = value.toISOString();
|
|
67
|
-
} else if (propMeta
|
|
72
|
+
} else if (propMeta.propertyType === Number && typeof value === 'number' && !Number.isFinite(value)) {
|
|
68
73
|
throw new Error(`Invalid number for property '${prop}' on component '${this._comp_name}': ${value}`);
|
|
69
74
|
}
|
|
70
75
|
}
|
|
71
76
|
data[prop] = value;
|
|
72
|
-
}
|
|
77
|
+
}
|
|
73
78
|
return data;
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
async save(trx: Bun.SQL, entity_id: string) {
|
|
77
|
-
|
|
82
|
+
// Level-gated: template literal allocates per component save even
|
|
83
|
+
// when trace is disabled.
|
|
84
|
+
if (logger.isLevelEnabled?.('trace')) {
|
|
85
|
+
logger.trace(`Saving component ${this._comp_name} for entity ${entity_id}`);
|
|
86
|
+
}
|
|
78
87
|
// Only check readiness if component is not yet registered
|
|
79
88
|
// This optimization avoids 40,000+ unnecessary async calls for bulk operations
|
|
80
89
|
if(!ComponentRegistry.isComponentReady(this._comp_name)) {
|
|
@@ -98,10 +107,9 @@ export class BaseComponent {
|
|
|
98
107
|
if (!entity_id || entity_id.trim() === '') {
|
|
99
108
|
throw new Error(`Cannot insert component ${this._comp_name}: entity_id is empty or invalid`);
|
|
100
109
|
}
|
|
101
|
-
await trx`INSERT INTO components
|
|
110
|
+
await trx`INSERT INTO components
|
|
102
111
|
(id, entity_id, name, type_id, data)
|
|
103
112
|
VALUES (${this.id}, ${entity_id}, ${this._comp_name}, ${this._typeId}, ${this.serializableData()})`
|
|
104
|
-
await trx`INSERT INTO entity_components (entity_id, type_id, component_id) VALUES (${entity_id}, ${this._typeId}, ${this.id}) ON CONFLICT DO NOTHING`
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
async update(trx: Bun.SQL) {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
GenerateTableName,
|
|
9
9
|
UpdateComponentIndexes,
|
|
10
10
|
AnalyzeAllComponentTables,
|
|
11
|
+
CreateRelationIndexes,
|
|
11
12
|
GetPartitionStrategy,
|
|
12
13
|
} from "../../database/DatabaseHelper";
|
|
13
14
|
import { ensureMultipleJSONBPathIndexes } from "../../database/IndexingStrategy";
|
|
@@ -34,6 +35,7 @@ class ComponentRegistry {
|
|
|
34
35
|
private readinessPromises = new Map<string, Promise<void>>();
|
|
35
36
|
private readinessResolvers = new Map<string, () => void>();
|
|
36
37
|
private componentsRegistered: boolean = false;
|
|
38
|
+
private cachedPartitionStrategy: string | null = null;
|
|
37
39
|
|
|
38
40
|
constructor() {}
|
|
39
41
|
|
|
@@ -200,6 +202,18 @@ class ComponentRegistry {
|
|
|
200
202
|
}
|
|
201
203
|
|
|
202
204
|
register(name: string, typeid: string, ctor: ComponentConstructor) {
|
|
205
|
+
// Warn when a LIST partition is being attached after startup registration
|
|
206
|
+
// completed. CREATE TABLE ... PARTITION OF takes ACCESS EXCLUSIVE on the
|
|
207
|
+
// parent `components` table, stalling all component I/O during the lock.
|
|
208
|
+
// Fine at boot; dangerous if a request triggers first use of a new type.
|
|
209
|
+
if (this.componentsRegistered && this.cachedPartitionStrategy === 'list') {
|
|
210
|
+
logger.warn(
|
|
211
|
+
`Runtime partition attach for component "${name}" takes ACCESS EXCLUSIVE on the ` +
|
|
212
|
+
`components table, stalling all component reads and writes until the DDL completes. ` +
|
|
213
|
+
`Pre-register all components at startup, or set BUNSANE_PARTITION_STRATEGY=hash ` +
|
|
214
|
+
`to avoid per-component partitions.`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
203
217
|
return new Promise<boolean>(async (resolve) => {
|
|
204
218
|
const partitionTableName = GenerateTableName(name);
|
|
205
219
|
// await this.populateCurrentTables();
|
|
@@ -289,6 +303,7 @@ class ComponentRegistry {
|
|
|
289
303
|
|
|
290
304
|
// Check partitioning strategy for index creation
|
|
291
305
|
const partitionStrategy = await GetPartitionStrategy();
|
|
306
|
+
this.cachedPartitionStrategy = partitionStrategy;
|
|
292
307
|
|
|
293
308
|
// Update component indexes for components that have indexed properties
|
|
294
309
|
// NOTE: Index operations are serialized to prevent deadlocks with ANALYZE
|
|
@@ -346,6 +361,19 @@ class ComponentRegistry {
|
|
|
346
361
|
}
|
|
347
362
|
logger.info(`Registered hooks for ${services.length} services`);
|
|
348
363
|
|
|
364
|
+
// Create btree indexes on archetype relation foreign-key fields
|
|
365
|
+
// (data->>'fk'). Without these, @BelongsTo/@HasMany resolver queries
|
|
366
|
+
// sequentially scan the relation component partition tables. Runs
|
|
367
|
+
// before ANALYZE so the planner picks up fresh stats for the new
|
|
368
|
+
// indexes. Idempotent (IF NOT EXISTS) and CONCURRENTLY on LIST
|
|
369
|
+
// partitions, so it is safe to re-run on every startup against live
|
|
370
|
+
// tables.
|
|
371
|
+
try {
|
|
372
|
+
await CreateRelationIndexes();
|
|
373
|
+
} catch (error) {
|
|
374
|
+
logger.warn(`Failed to create relation FK indexes: ${error}`);
|
|
375
|
+
}
|
|
376
|
+
|
|
349
377
|
// Run ANALYZE on all component tables to update query planner statistics
|
|
350
378
|
await AnalyzeAllComponentTables();
|
|
351
379
|
}
|
|
@@ -11,7 +11,7 @@ import { getMetadataStorage } from '../metadata';
|
|
|
11
11
|
* - 'numeric': BTREE index with numeric cast for range queries (>, <, BETWEEN)
|
|
12
12
|
* @param isDateField Whether this field contains date values (affects BTREE index casting)
|
|
13
13
|
*/
|
|
14
|
-
export function IndexedField(indexType: 'gin' | 'btree' | 'hash' | 'numeric' = 'gin', isDateField: boolean = false) {
|
|
14
|
+
export function IndexedField(indexType: 'gin' | 'btree' | 'hash' | 'numeric' | 'fulltext' = 'gin', isDateField: boolean = false) {
|
|
15
15
|
return function(target: any, propertyKey: string) {
|
|
16
16
|
const storage = getMetadataStorage();
|
|
17
17
|
const componentId = storage.getComponentId(target.constructor.name);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Cache side-effect strategies for Entity save/delete. Extracted from
|
|
2
|
+
// Entity.ts (RFC_REFACTOR_TARGETS §3.2). Pure functions take the entity
|
|
3
|
+
// instance as the first parameter.
|
|
4
|
+
import type { BaseComponent } from "../components";
|
|
5
|
+
import { logger } from "../Logger";
|
|
6
|
+
import EntityHookManager from "../EntityHookManager";
|
|
7
|
+
import { EntityDeletedEvent } from "../events/EntityLifecycleEvents";
|
|
8
|
+
import type { SQL } from "bun";
|
|
9
|
+
import type { Entity } from "../Entity";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Handle cache operations after successful save
|
|
13
|
+
* @param changedComponentTypeIds - Component type IDs that were dirty before save (captured before doSave clears flags)
|
|
14
|
+
* @param removedComponentTypeIds - Component type IDs that were removed (captured before doSave clears the set)
|
|
15
|
+
*/
|
|
16
|
+
export async function handleCacheAfterSave(entity: Entity, changedComponentTypeIds: string[], removedComponentTypeIds: string[], context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): Promise<void> {
|
|
17
|
+
try {
|
|
18
|
+
// Import CacheManager dynamically to avoid circular dependency
|
|
19
|
+
const { CacheManager } = await import('../cache/CacheManager');
|
|
20
|
+
const cacheManager = CacheManager.getInstance();
|
|
21
|
+
const config = cacheManager.getConfig();
|
|
22
|
+
|
|
23
|
+
const entityEnabled = !!(config.enabled && config.entity?.enabled);
|
|
24
|
+
const componentEnabled = !!(config.enabled && config.component?.enabled);
|
|
25
|
+
|
|
26
|
+
if (entityEnabled && config.strategy === 'write-through') {
|
|
27
|
+
await cacheManager.setEntityWriteThrough(entity, config.entity!.ttl);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Handle component cache invalidation with granular approach
|
|
31
|
+
if (componentEnabled) {
|
|
32
|
+
// Use the pre-captured lists instead of re-querying (dirty flags are already cleared by doSave)
|
|
33
|
+
|
|
34
|
+
if (config.strategy === 'write-through') {
|
|
35
|
+
// Single batched write-through (2 pipelined provider
|
|
36
|
+
// round-trips total) instead of one GET+SET pair per
|
|
37
|
+
// changed component.
|
|
38
|
+
const entries = changedComponentTypeIds
|
|
39
|
+
.map(typeId => ({ typeId, component: entity.components.get(typeId) }))
|
|
40
|
+
.filter((e): e is { typeId: string; component: BaseComponent } => !!e.component)
|
|
41
|
+
.map(e => ({ entityId: entity.id, typeId: e.typeId, component: e.component, ttl: config.component!.ttl }));
|
|
42
|
+
if (entries.length > 0) {
|
|
43
|
+
await cacheManager.setComponentsBatchWriteThrough(entries);
|
|
44
|
+
}
|
|
45
|
+
// Removed components must still drop out of cache.
|
|
46
|
+
if (removedComponentTypeIds.length > 0) {
|
|
47
|
+
await cacheManager.invalidateEntityComponents(entity.id, removedComponentTypeIds);
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
// One deleteMany + ONE pub/sub message for the whole save
|
|
51
|
+
// (entity key included) — previously N+1 DEL+PUBLISH pairs
|
|
52
|
+
// per save, fanning out to every other instance.
|
|
53
|
+
const toInvalidate = [...changedComponentTypeIds, ...removedComponentTypeIds];
|
|
54
|
+
if (toInvalidate.length > 0 || entityEnabled) {
|
|
55
|
+
await cacheManager.invalidateEntityComponents(entity.id, toInvalidate, { includeEntityKey: entityEnabled });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Invalidate DataLoader cache for changed + removed components
|
|
60
|
+
if (context?.loaders?.componentsByEntityType) {
|
|
61
|
+
for (const typeId of [...changedComponentTypeIds, ...removedComponentTypeIds]) {
|
|
62
|
+
context.loaders.componentsByEntityType.clear({
|
|
63
|
+
entityId: entity.id,
|
|
64
|
+
typeId: typeId
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} else if (entityEnabled && config.strategy !== 'write-through') {
|
|
69
|
+
await cacheManager.invalidateEntity(entity.id);
|
|
70
|
+
}
|
|
71
|
+
} catch (error) {
|
|
72
|
+
logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache operation failed after save', error });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function runPostDeleteSideEffects(entity: Entity, softDelete: boolean): Promise<void> {
|
|
77
|
+
try {
|
|
78
|
+
await EntityHookManager.executeHooks(new EntityDeletedEvent(entity, softDelete));
|
|
79
|
+
} catch (err) {
|
|
80
|
+
logger.error({ scope: 'hooks', entityId: entity.id, err }, 'post-delete lifecycle hooks failed');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const { CacheManager } = await import('../cache/CacheManager');
|
|
85
|
+
const cacheManager = CacheManager.getInstance();
|
|
86
|
+
const config = cacheManager.getConfig();
|
|
87
|
+
|
|
88
|
+
if (config.enabled && config.entity?.enabled) {
|
|
89
|
+
await cacheManager.invalidateEntity(entity.id);
|
|
90
|
+
}
|
|
91
|
+
if (config.enabled && config.component?.enabled) {
|
|
92
|
+
await cacheManager.invalidateAllEntityComponents(entity.id);
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
logger.warn({ scope: 'cache', entityId: entity.id, err }, 'post-delete cache invalidation failed');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// Component access + mutation for Entity (add/set/remove/get/has/reload
|
|
2
|
+
// and the in-memory helpers). Extracted from Entity.ts
|
|
3
|
+
// (RFC_REFACTOR_TARGETS §3.2). Pure functions take the entity instance as
|
|
4
|
+
// the first parameter; hook phases/order are byte-identical to the
|
|
5
|
+
// original inline implementation.
|
|
6
|
+
import type { ComponentDataType, ComponentGetter, BaseComponent } from "../components";
|
|
7
|
+
import { logger } from "../Logger";
|
|
8
|
+
import db from "../../database";
|
|
9
|
+
import { runWithSignal } from "../../database/cancellable";
|
|
10
|
+
import ComponentRegistry from "../components/ComponentRegistry";
|
|
11
|
+
import { SQL } from "bun";
|
|
12
|
+
import EntityHookManager from "../EntityHookManager";
|
|
13
|
+
import { getMetadataStorage } from "../metadata";
|
|
14
|
+
import { ComponentAddedEvent, ComponentUpdatedEvent, ComponentRemovedEvent } from "../events/EntityLifecycleEvents";
|
|
15
|
+
import { getRequestScope } from "../requestScope";
|
|
16
|
+
import { trackCacheOp } from "./pendingOps";
|
|
17
|
+
import type { Entity } from "../Entity";
|
|
18
|
+
|
|
19
|
+
export function addComponent(entity: Entity, component: BaseComponent): Entity {
|
|
20
|
+
entity.components.set(component.getTypeID(), component);
|
|
21
|
+
return entity;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a component constructor to its type id. `getComponentId` is
|
|
26
|
+
* memoized in metadata storage, so this is an O(1) Map lookup with no
|
|
27
|
+
* component instantiation — unlike `new ctor().getTypeID()`. The
|
|
28
|
+
* `components` map is keyed by type id (see addComponent), so callers can
|
|
29
|
+
* then do `entity.components.get(typeId)` instead of allocating an array and
|
|
30
|
+
* scanning it with `instanceof`.
|
|
31
|
+
*/
|
|
32
|
+
export function typeIdOf(ctor: new (...args: any[]) => BaseComponent): string {
|
|
33
|
+
return getMetadataStorage().getComponentId(ctor.name);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function componentList(entity: Entity): BaseComponent[] {
|
|
37
|
+
return Array.from(entity.components.values());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getInMemory<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): T | undefined {
|
|
41
|
+
return entity.components.get(typeIdOf(ctor)) as T | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function hasInMemory<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): boolean {
|
|
45
|
+
return entity.components.has(typeIdOf(ctor));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function wasRemoved<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): boolean {
|
|
49
|
+
const typeId = typeIdOf(ctor);
|
|
50
|
+
// Check both pending removals and already-saved removals
|
|
51
|
+
return entity.removedComponents.has(typeId) || entity.savedRemovedComponents.has(typeId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function add<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, data?: Partial<ComponentDataType<T>>): Entity {
|
|
55
|
+
const instance = new ctor();
|
|
56
|
+
if (data) {
|
|
57
|
+
Object.assign(instance, data);
|
|
58
|
+
} else {
|
|
59
|
+
Object.assign(instance, {});
|
|
60
|
+
}
|
|
61
|
+
addComponent(entity, instance);
|
|
62
|
+
entity.setDirty(true);
|
|
63
|
+
// executeHooks is async; the surrounding try/catch only captures
|
|
64
|
+
// synchronous throws. Attach a .catch so an async rejection from a
|
|
65
|
+
// hook handler does not escape as an unhandled rejection (H-HOOK-1).
|
|
66
|
+
// Add stays sync to preserve the fluent chaining signature; hook
|
|
67
|
+
// failures are logged and do not fail the add operation.
|
|
68
|
+
Promise.resolve()
|
|
69
|
+
.then(() => EntityHookManager.executeHooks(new ComponentAddedEvent(entity, instance)))
|
|
70
|
+
.catch((error) => {
|
|
71
|
+
logger.error(`Error firing component added hook for ${instance.getTypeID()}: ${error}`);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return entity;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function set<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, data: Partial<ComponentDataType<T>>, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): Promise<Entity> {
|
|
78
|
+
await get(entity, ctor, context);
|
|
79
|
+
|
|
80
|
+
const component = entity.components.get(typeIdOf(ctor)) as T;
|
|
81
|
+
if (component) {
|
|
82
|
+
// Store old data for the update event
|
|
83
|
+
const oldData = { ...component };
|
|
84
|
+
|
|
85
|
+
// Update existing component
|
|
86
|
+
Object.assign(component, data);
|
|
87
|
+
component.setDirty(true);
|
|
88
|
+
entity.setDirty(true);
|
|
89
|
+
|
|
90
|
+
// Fire component updated event. Await so a hook rejection is
|
|
91
|
+
// captured by this method's try/catch and does not escape as an
|
|
92
|
+
// unhandled rejection (H-HOOK-1).
|
|
93
|
+
try {
|
|
94
|
+
await EntityHookManager.executeHooks(new ComponentUpdatedEvent(entity, component, oldData, component));
|
|
95
|
+
} catch (error) {
|
|
96
|
+
logger.error(`Error firing component updated hook for ${component.getTypeID()}: ${error}`);
|
|
97
|
+
// Don't fail the set operation if hooks fail
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Invalidate DataLoader cache if context is provided
|
|
101
|
+
if (context?.loaders?.componentsByEntityType) {
|
|
102
|
+
context.loaders.componentsByEntityType.clear({
|
|
103
|
+
entityId: entity.id,
|
|
104
|
+
typeId: component.getTypeID()
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Fire-and-forget cache update, tracked via drainable set so
|
|
109
|
+
// App.shutdown can await it (H-CACHE-1).
|
|
110
|
+
trackCacheOp((async () => {
|
|
111
|
+
try {
|
|
112
|
+
const { CacheManager } = await import('../cache/CacheManager');
|
|
113
|
+
const cacheManager = CacheManager.getInstance();
|
|
114
|
+
const config = cacheManager.getConfig();
|
|
115
|
+
|
|
116
|
+
if (config.enabled && config.component?.enabled) {
|
|
117
|
+
if (config.strategy === 'write-through') {
|
|
118
|
+
await cacheManager.setComponentWriteThrough(entity.id, [component], component.getTypeID(), config.component.ttl);
|
|
119
|
+
} else {
|
|
120
|
+
await cacheManager.invalidateComponent(entity.id, component.getTypeID());
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache operation failed after set', err: error });
|
|
125
|
+
}
|
|
126
|
+
})());
|
|
127
|
+
} else {
|
|
128
|
+
// Add new component
|
|
129
|
+
add(entity, ctor, data);
|
|
130
|
+
entity.setDirty(true);
|
|
131
|
+
// Note: add() already fires ComponentAddedEvent, so we don't need to fire it again
|
|
132
|
+
}
|
|
133
|
+
return entity;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function remove<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): boolean {
|
|
137
|
+
const component = entity.components.get(typeIdOf(ctor)) as T;
|
|
138
|
+
|
|
139
|
+
if (component) {
|
|
140
|
+
const typeId = component.getTypeID();
|
|
141
|
+
|
|
142
|
+
// Track the component type for database deletion
|
|
143
|
+
entity.removedComponents.add(typeId);
|
|
144
|
+
|
|
145
|
+
// Remove the component from the map
|
|
146
|
+
entity.components.delete(typeId);
|
|
147
|
+
entity.setDirty(true);
|
|
148
|
+
|
|
149
|
+
// Fire component removed event. remove() stays sync to preserve
|
|
150
|
+
// the boolean return signature used by callers; attach .catch so
|
|
151
|
+
// async hook rejections do not escape (H-HOOK-1).
|
|
152
|
+
Promise.resolve()
|
|
153
|
+
.then(() => EntityHookManager.executeHooks(new ComponentRemovedEvent(entity, component)))
|
|
154
|
+
.catch((error) => {
|
|
155
|
+
logger.error(`Error firing component removed hook for ${typeId}: ${error}`);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Invalidate DataLoader cache if context is provided
|
|
159
|
+
if (context?.loaders?.componentsByEntityType) {
|
|
160
|
+
context.loaders.componentsByEntityType.clear({
|
|
161
|
+
entityId: entity.id,
|
|
162
|
+
typeId: typeId
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Fire-and-forget cache invalidation, tracked for shutdown drain
|
|
167
|
+
// (H-CACHE-1).
|
|
168
|
+
trackCacheOp((async () => {
|
|
169
|
+
try {
|
|
170
|
+
const { CacheManager } = await import('../cache/CacheManager');
|
|
171
|
+
const cacheManager = CacheManager.getInstance();
|
|
172
|
+
const config = cacheManager.getConfig();
|
|
173
|
+
|
|
174
|
+
if (config.enabled && config.component?.enabled) {
|
|
175
|
+
await cacheManager.invalidateComponent(entity.id, typeId);
|
|
176
|
+
}
|
|
177
|
+
} catch (error) {
|
|
178
|
+
logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache invalidation failed after remove', err: error });
|
|
179
|
+
}
|
|
180
|
+
})());
|
|
181
|
+
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function get<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): Promise<ComponentDataType<T> | null> {
|
|
189
|
+
const comp = await loadComponent(entity, ctor, context);
|
|
190
|
+
return comp ? (comp as ComponentGetter<T>).data() : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function has<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): boolean {
|
|
194
|
+
return hasInMemory(entity, ctor);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function getOrThrow<T extends BaseComponent>(
|
|
198
|
+
entity: Entity,
|
|
199
|
+
ctor: new (...args: any[]) => T,
|
|
200
|
+
context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }
|
|
201
|
+
): Promise<ComponentDataType<T>> {
|
|
202
|
+
const data = await get(entity, ctor, context);
|
|
203
|
+
if (data === null) {
|
|
204
|
+
throw new Error(`Entity ${entity.id} is missing required component ${ctor.name}`);
|
|
205
|
+
}
|
|
206
|
+
return data;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function getCached<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): ComponentDataType<T> | undefined {
|
|
210
|
+
const comp = getInMemory(entity, ctor);
|
|
211
|
+
return comp ? (comp as ComponentGetter<T>).data() : undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function getInstanceOf<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): Promise<T | null> {
|
|
215
|
+
return loadComponent(entity, ctor, context);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function reload(entity: Entity, opts?: { trx?: SQL; signal?: AbortSignal }): Promise<Entity> {
|
|
219
|
+
if (!entity.id || entity.id.trim() === '') {
|
|
220
|
+
return entity;
|
|
221
|
+
}
|
|
222
|
+
entity.components.clear();
|
|
223
|
+
entity.removedComponents.clear();
|
|
224
|
+
entity.savedRemovedComponents.clear();
|
|
225
|
+
|
|
226
|
+
const dbConn = opts?.trx ?? db;
|
|
227
|
+
const rows = await runWithSignal<any[]>(
|
|
228
|
+
dbConn`
|
|
229
|
+
SELECT c.id, c.type_id, c.data
|
|
230
|
+
FROM components c
|
|
231
|
+
WHERE c.entity_id = ${entity.id} AND c.deleted_at IS NULL
|
|
232
|
+
`,
|
|
233
|
+
opts?.signal
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const storage = getMetadataStorage();
|
|
237
|
+
for (const row of rows) {
|
|
238
|
+
const ctor = ComponentRegistry.getConstructor(row.type_id);
|
|
239
|
+
if (!ctor) continue;
|
|
240
|
+
const comp: any = new ctor();
|
|
241
|
+
const parsed = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
|
|
242
|
+
Object.assign(comp, parsed);
|
|
243
|
+
comp.id = row.id;
|
|
244
|
+
const props = storage.componentProperties.get(row.type_id);
|
|
245
|
+
if (props) {
|
|
246
|
+
for (const prop of props) {
|
|
247
|
+
if (prop.propertyType === Date && typeof comp[prop.propertyKey] === 'string') {
|
|
248
|
+
comp[prop.propertyKey] = new Date(comp[prop.propertyKey]);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
comp.setPersisted(true);
|
|
253
|
+
comp.setDirty(false);
|
|
254
|
+
addComponent(entity, comp);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
entity.setPersisted(true);
|
|
258
|
+
entity.setDirty(false);
|
|
259
|
+
return entity;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function requireComponents(entity: Entity, ctors: Array<new (...args: any[]) => BaseComponent>): Promise<void> {
|
|
263
|
+
if (ctors.length === 0) return;
|
|
264
|
+
const missing: string[] = [];
|
|
265
|
+
for (const ctor of ctors) {
|
|
266
|
+
// components is keyed by type id — O(1) lookup, no instantiation
|
|
267
|
+
// and no O(K) instanceof scan per constructor.
|
|
268
|
+
const typeId = typeIdOf(ctor);
|
|
269
|
+
if (!entity.components.has(typeId)) {
|
|
270
|
+
missing.push(typeId);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (missing.length === 0) return;
|
|
274
|
+
const { Entity } = await import("../Entity");
|
|
275
|
+
await Entity.LoadComponents([entity], missing);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function loadComponent<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): Promise<T | null> {
|
|
279
|
+
const comp = entity.components.get(typeIdOf(ctor)) as T | undefined;
|
|
280
|
+
if (typeof comp !== "undefined") {
|
|
281
|
+
return comp;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Validate entity ID before database query
|
|
285
|
+
if (!entity.id || entity.id.trim() === '') {
|
|
286
|
+
logger.warn(`Cannot load component ${ctor.name}: entity id is empty`);
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Memoized metadata lookup — no throwaway component instantiation
|
|
291
|
+
// just to read the type id.
|
|
292
|
+
const typeId = typeIdOf(ctor);
|
|
293
|
+
|
|
294
|
+
// Use transaction if provided, otherwise use default db
|
|
295
|
+
const dbConn = context?.trx ?? db;
|
|
296
|
+
|
|
297
|
+
// Ambient request scope fallback: bare entity.get() calls (e.g.
|
|
298
|
+
// inside @ArcheTypeFunction bodies or Unwrap()) batch through the
|
|
299
|
+
// request's DataLoaders instead of firing one SELECT per call.
|
|
300
|
+
// Never substituted when the caller passed an explicit trx — a
|
|
301
|
+
// loader read outside the transaction could see stale data.
|
|
302
|
+
const scope = (!context?.loaders && !context?.trx) ? getRequestScope() : undefined;
|
|
303
|
+
const loaders = context?.loaders ?? scope?.loaders;
|
|
304
|
+
const signal = context?.signal ?? scope?.signal;
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
let componentData: any = null;
|
|
308
|
+
let componentId: string | null = null;
|
|
309
|
+
|
|
310
|
+
if (loaders?.componentsByEntityType) {
|
|
311
|
+
const loaderResult = await loaders.componentsByEntityType.load({
|
|
312
|
+
entityId: entity.id,
|
|
313
|
+
typeId: typeId
|
|
314
|
+
});
|
|
315
|
+
if (loaderResult) {
|
|
316
|
+
componentData = loaderResult.data;
|
|
317
|
+
componentId = loaderResult.id;
|
|
318
|
+
}
|
|
319
|
+
} else {
|
|
320
|
+
// Route through runWithSignal so a request/wall-clock abort can
|
|
321
|
+
// cancel this in-flight read. When dbConn is context.trx, an
|
|
322
|
+
// uncancelled read leaks the backend into `idle in transaction`
|
|
323
|
+
// on timeout (matches the d1dde84 save/delete fix, which missed
|
|
324
|
+
// the read path).
|
|
325
|
+
const rows = await runWithSignal<any[]>(
|
|
326
|
+
dbConn`SELECT id, data FROM components WHERE entity_id = ${entity.id} AND type_id = ${typeId} AND deleted_at IS NULL`,
|
|
327
|
+
signal
|
|
328
|
+
);
|
|
329
|
+
if (rows.length > 0) {
|
|
330
|
+
componentData = rows[0].data;
|
|
331
|
+
componentId = rows[0].id;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (componentData !== null) {
|
|
336
|
+
const comp: any = new ctor();
|
|
337
|
+
if (componentId) {
|
|
338
|
+
comp.id = componentId;
|
|
339
|
+
}
|
|
340
|
+
const parsedData = typeof componentData === 'string' ? JSON.parse(componentData) : componentData;
|
|
341
|
+
Object.assign(comp, parsedData);
|
|
342
|
+
const storage = getMetadataStorage();
|
|
343
|
+
const props = storage.componentProperties.get(typeId);
|
|
344
|
+
if (props) {
|
|
345
|
+
for (const prop of props) {
|
|
346
|
+
if (prop.propertyType === Date && typeof comp[prop.propertyKey] === 'string') {
|
|
347
|
+
comp[prop.propertyKey] = new Date(comp[prop.propertyKey]);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
comp.setPersisted(true);
|
|
352
|
+
comp.setDirty(false);
|
|
353
|
+
addComponent(entity, comp);
|
|
354
|
+
return comp as T;
|
|
355
|
+
} else {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
} catch (error) {
|
|
359
|
+
logger.error(`Failed to fetch component ${ctor.name}: ${error}`);
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export { loadComponent };
|