bunsane 0.1.4 → 0.2.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/.claude/settings.local.json +47 -0
- package/.claude/skills/update-memory.md +74 -0
- package/.prettierrc +4 -0
- package/.serena/memories/architectural-decision-no-dependency-injection.md +76 -0
- package/.serena/memories/architecture.md +154 -0
- package/.serena/memories/cache-interface-refactoring-2026-01-24.md +165 -0
- package/.serena/memories/code_style_and_conventions.md +76 -0
- package/.serena/memories/project_overview.md +43 -0
- package/.serena/memories/schema-dsl-plan.md +107 -0
- package/.serena/memories/suggested_commands.md +80 -0
- package/.serena/memories/typescript-compilation-status.md +54 -0
- package/.serena/project.yml +114 -0
- package/TODO.md +1 -7
- package/bun.lock +150 -4
- package/bunfig.toml +10 -0
- package/config/cache.config.ts +77 -0
- package/config/upload.config.ts +4 -5
- package/core/App.ts +870 -123
- package/core/ArcheType.ts +2268 -377
- package/core/BatchLoader.ts +181 -71
- package/core/Config.ts +153 -0
- package/core/Decorators.ts +4 -1
- package/core/Entity.ts +621 -92
- package/core/EntityHookManager.ts +1 -1
- package/core/EntityInterface.ts +3 -1
- package/core/EntityManager.ts +1 -13
- package/core/ErrorHandler.ts +8 -2
- package/core/Logger.ts +9 -0
- package/core/Middleware.ts +34 -0
- package/core/RequestContext.ts +5 -1
- package/core/RequestLoaders.ts +227 -93
- package/core/SchedulerManager.ts +193 -52
- package/core/cache/CacheAnalytics.ts +399 -0
- package/core/cache/CacheFactory.ts +145 -0
- package/core/cache/CacheManager.ts +520 -0
- package/core/cache/CacheProvider.ts +34 -0
- package/core/cache/CacheWarmer.ts +157 -0
- package/core/cache/CompressionUtils.ts +110 -0
- package/core/cache/MemoryCache.ts +251 -0
- package/core/cache/MultiLevelCache.ts +180 -0
- package/core/cache/NoOpCache.ts +53 -0
- package/core/cache/RedisCache.ts +464 -0
- package/core/cache/TTLStrategy.ts +254 -0
- package/core/cache/index.ts +6 -0
- package/core/components/BaseComponent.ts +120 -0
- package/core/{ComponentRegistry.ts → components/ComponentRegistry.ts} +148 -54
- package/core/components/Decorators.ts +88 -0
- package/core/components/Interfaces.ts +7 -0
- package/core/components/index.ts +5 -0
- package/core/decorators/EntityHooks.ts +0 -3
- package/core/decorators/IndexedField.ts +26 -0
- package/core/decorators/ScheduledTask.ts +0 -47
- package/core/events/EntityLifecycleEvents.ts +1 -1
- package/core/health.ts +112 -0
- package/core/metadata/definitions/ArcheType.ts +14 -0
- package/core/metadata/definitions/Component.ts +9 -0
- package/core/metadata/definitions/gqlObject.ts +1 -1
- package/core/metadata/index.ts +42 -1
- package/core/metadata/metadata-storage.ts +28 -2
- package/core/middleware/AccessLog.ts +59 -0
- package/core/middleware/RequestId.ts +38 -0
- package/core/middleware/SecurityHeaders.ts +62 -0
- package/core/middleware/index.ts +3 -0
- package/core/scheduler/DistributedLock.ts +266 -0
- package/core/scheduler/index.ts +15 -0
- package/core/validateEnv.ts +92 -0
- package/database/DatabaseHelper.ts +416 -40
- package/database/IndexingStrategy.ts +342 -0
- package/database/PreparedStatementCache.ts +226 -0
- package/database/index.ts +32 -7
- package/database/sqlHelpers.ts +14 -2
- package/endpoints/archetypes.ts +362 -0
- package/endpoints/components.ts +58 -0
- package/endpoints/entity.ts +80 -0
- package/endpoints/index.ts +27 -0
- package/endpoints/query.ts +93 -0
- package/endpoints/stats.ts +76 -0
- package/endpoints/tables.ts +212 -0
- package/endpoints/types.ts +155 -0
- package/gql/ArchetypeOperations.ts +32 -86
- package/gql/Generator.ts +27 -315
- package/gql/GeneratorV2.ts +37 -0
- package/gql/builders/InputTypeBuilder.ts +99 -0
- package/gql/builders/ResolverBuilder.ts +234 -0
- package/gql/builders/TypeDefBuilder.ts +105 -0
- package/gql/builders/index.ts +3 -0
- package/gql/decorators/Upload.ts +1 -1
- package/gql/depthLimit.ts +85 -0
- package/gql/graph/GraphNode.ts +224 -0
- package/gql/graph/SchemaGraph.ts +278 -0
- package/gql/helpers.ts +8 -2
- package/gql/index.ts +56 -4
- package/gql/middleware.ts +79 -0
- package/gql/orchestration/GraphQLSchemaOrchestrator.ts +241 -0
- package/gql/orchestration/index.ts +1 -0
- package/gql/scanner/ServiceScanner.ts +347 -0
- package/gql/schema/index.ts +458 -0
- package/gql/strategies/TypeGenerationStrategy.ts +329 -0
- package/gql/types.ts +1 -0
- package/gql/utils/TypeSignature.ts +220 -0
- package/gql/utils/index.ts +1 -0
- package/gql/visitors/ArchetypePreprocessorVisitor.ts +80 -0
- package/gql/visitors/DeduplicationVisitor.ts +82 -0
- package/gql/visitors/GraphVisitor.ts +78 -0
- package/gql/visitors/ResolverGeneratorVisitor.ts +122 -0
- package/gql/visitors/SchemaGeneratorVisitor.ts +851 -0
- package/gql/visitors/TypeCollectorVisitor.ts +79 -0
- package/gql/visitors/VisitorComposer.ts +96 -0
- package/gql/visitors/index.ts +7 -0
- package/package.json +59 -37
- package/plugins/index.ts +2 -2
- package/query/CTENode.ts +97 -0
- package/query/ComponentInclusionNode.ts +689 -0
- package/query/FilterBuilder.ts +127 -0
- package/query/FilterBuilderRegistry.ts +202 -0
- package/query/OrNode.ts +517 -0
- package/query/OrQuery.ts +42 -0
- package/query/Query.ts +1022 -0
- package/query/QueryContext.ts +170 -0
- package/query/QueryDAG.ts +122 -0
- package/query/QueryNode.ts +65 -0
- package/query/SourceNode.ts +53 -0
- package/query/builders/FullTextSearchBuilder.ts +236 -0
- package/query/index.ts +21 -0
- package/scheduler/index.ts +40 -8
- package/service/Service.ts +2 -1
- package/service/ServiceRegistry.ts +6 -5
- package/{core/storage → storage}/LocalStorageProvider.ts +2 -2
- package/storage/S3StorageProvider.ts +316 -0
- package/{core/storage → storage}/StorageProvider.ts +7 -3
- package/studio/bun.lock +482 -0
- package/studio/index.html +13 -0
- package/studio/package.json +39 -0
- package/studio/postcss.config.js +6 -0
- package/studio/src/components/DataTable.tsx +211 -0
- package/studio/src/components/Layout.tsx +13 -0
- package/studio/src/components/PageContainer.tsx +9 -0
- package/studio/src/components/PageHeader.tsx +13 -0
- package/studio/src/components/SearchBar.tsx +57 -0
- package/studio/src/components/Sidebar.tsx +294 -0
- package/studio/src/components/ui/button.tsx +56 -0
- package/studio/src/components/ui/checkbox.tsx +26 -0
- package/studio/src/components/ui/input.tsx +25 -0
- package/studio/src/hooks/useDataTable.ts +131 -0
- package/studio/src/index.css +36 -0
- package/studio/src/lib/api.ts +186 -0
- package/studio/src/lib/utils.ts +13 -0
- package/studio/src/main.tsx +17 -0
- package/studio/src/pages/ArcheType.tsx +239 -0
- package/studio/src/pages/Components.tsx +124 -0
- package/studio/src/pages/EntityInspector.tsx +302 -0
- package/studio/src/pages/QueryRunner.tsx +246 -0
- package/studio/src/pages/Table.tsx +94 -0
- package/studio/src/pages/Welcome.tsx +241 -0
- package/studio/src/routes.tsx +45 -0
- package/studio/src/store/archeTypeSettings.ts +30 -0
- package/studio/src/store/studio.ts +65 -0
- package/studio/src/utils/columnHelpers.tsx +114 -0
- package/studio/studio-instructions.md +81 -0
- package/studio/tailwind.config.js +77 -0
- package/studio/tsconfig.json +24 -0
- package/studio/utils.ts +54 -0
- package/studio/vite.config.js +19 -0
- package/swagger/generator.ts +1 -1
- package/tests/e2e/http.test.ts +126 -0
- package/tests/fixtures/archetypes/TestUserArchetype.ts +21 -0
- package/tests/fixtures/components/TestOrder.ts +23 -0
- package/tests/fixtures/components/TestProduct.ts +23 -0
- package/tests/fixtures/components/TestUser.ts +20 -0
- package/tests/fixtures/components/index.ts +6 -0
- package/tests/graphql/SchemaGeneration.test.ts +90 -0
- package/tests/graphql/builders/ResolverBuilder.test.ts +223 -0
- package/tests/graphql/builders/TypeDefBuilder.test.ts +153 -0
- package/tests/integration/archetype/ArcheType.persistence.test.ts +241 -0
- package/tests/integration/cache/CacheInvalidation.test.ts +259 -0
- package/tests/integration/entity/Entity.persistence.test.ts +333 -0
- package/tests/integration/query/Query.exec.test.ts +523 -0
- package/tests/pglite-setup.ts +61 -0
- package/tests/setup.ts +164 -0
- package/tests/stress/BenchmarkRunner.ts +203 -0
- package/tests/stress/DataSeeder.ts +190 -0
- package/tests/stress/StressTestReporter.ts +229 -0
- package/tests/stress/cursor-perf-test.ts +171 -0
- package/tests/stress/fixtures/StressTestComponents.ts +58 -0
- package/tests/stress/index.ts +7 -0
- package/tests/stress/scenarios/query-benchmarks.test.ts +285 -0
- package/tests/unit/BatchLoader.test.ts +82 -0
- package/tests/unit/archetype/ArcheType.test.ts +107 -0
- package/tests/unit/cache/CacheManager.test.ts +347 -0
- package/tests/unit/cache/MemoryCache.test.ts +260 -0
- package/tests/unit/cache/RedisCache.test.ts +411 -0
- package/tests/unit/entity/Entity.components.test.ts +244 -0
- package/tests/unit/entity/Entity.test.ts +345 -0
- package/tests/unit/gql/depthLimit.test.ts +203 -0
- package/tests/unit/gql/operationMiddleware.test.ts +293 -0
- package/tests/unit/health/Health.test.ts +129 -0
- package/tests/unit/middleware/AccessLog.test.ts +37 -0
- package/tests/unit/middleware/Middleware.test.ts +98 -0
- package/tests/unit/middleware/RequestId.test.ts +54 -0
- package/tests/unit/middleware/SecurityHeaders.test.ts +66 -0
- package/tests/unit/query/FilterBuilder.test.ts +111 -0
- package/tests/unit/query/Query.test.ts +308 -0
- package/tests/unit/scheduler/DistributedLock.test.ts +274 -0
- package/tests/unit/schema/schema-integration.test.ts +426 -0
- package/tests/unit/schema/schema.test.ts +580 -0
- package/tests/unit/storage/S3StorageProvider.test.ts +571 -0
- package/tests/unit/upload/RestUpload.test.ts +267 -0
- package/tests/unit/validateEnv.test.ts +82 -0
- package/tests/utils/entity-tracker.ts +57 -0
- package/tests/utils/index.ts +13 -0
- package/tests/utils/test-context.ts +149 -0
- package/tsconfig.json +5 -1
- package/types/archetype.types.ts +6 -0
- package/types/hooks.types.ts +1 -1
- package/types/query.types.ts +110 -0
- package/types/scheduler.types.ts +68 -7
- package/types/upload.types.ts +1 -0
- package/{core → upload}/FileValidator.ts +10 -1
- package/upload/RestUpload.ts +130 -0
- package/{core/components → upload}/UploadComponent.ts +11 -11
- package/{core → upload}/UploadManager.ts +3 -3
- package/upload/index.ts +23 -7
- package/utils/UploadHelper.ts +27 -6
- package/utils/cronParser.ts +16 -6
- package/.github/workflows/deploy-docs.yml +0 -57
- package/core/Components.ts +0 -202
- package/core/EntityCache.ts +0 -15
- package/core/Query.ts +0 -880
- package/docs/README.md +0 -149
- package/docs/_coverpage.md +0 -36
- package/docs/_sidebar.md +0 -23
- package/docs/api/core.md +0 -568
- package/docs/api/hooks.md +0 -554
- package/docs/api/index.md +0 -222
- package/docs/api/query.md +0 -678
- package/docs/api/service.md +0 -744
- package/docs/core-concepts/archetypes.md +0 -512
- package/docs/core-concepts/components.md +0 -498
- package/docs/core-concepts/entity.md +0 -314
- package/docs/core-concepts/hooks.md +0 -683
- package/docs/core-concepts/query.md +0 -588
- package/docs/core-concepts/services.md +0 -647
- package/docs/examples/code-examples.md +0 -425
- package/docs/getting-started.md +0 -337
- package/docs/index.html +0 -97
- package/tests/bench/insert.bench.ts +0 -60
- package/tests/bench/relations.bench.ts +0 -270
- package/tests/bench/sorting.bench.ts +0 -416
- package/tests/component-hooks-simple.test.ts +0 -117
- package/tests/component-hooks.test.ts +0 -1461
- package/tests/component.test.ts +0 -339
- package/tests/errorHandling.test.ts +0 -155
- package/tests/hooks.test.ts +0 -667
- package/tests/query-sorting.test.ts +0 -101
- package/tests/query.test.ts +0 -81
- package/tests/relations.test.ts +0 -170
- package/tests/scheduler.test.ts +0 -724
package/core/Entity.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import type { ComponentDataType, ComponentGetter, BaseComponent } from "./
|
|
1
|
+
import type { ComponentDataType, ComponentGetter, BaseComponent } from "./components";
|
|
2
2
|
import { logger } from "./Logger";
|
|
3
|
-
import db from "database";
|
|
3
|
+
import db from "../database";
|
|
4
4
|
import EntityManager from "./EntityManager";
|
|
5
|
-
import ComponentRegistry from "./ComponentRegistry";
|
|
6
|
-
import { uuidv7 } from "utils/uuid";
|
|
7
|
-
import { sql } from "bun";
|
|
5
|
+
import ComponentRegistry from "./components/ComponentRegistry";
|
|
6
|
+
import { uuidv7 } from "../utils/uuid";
|
|
7
|
+
import { sql, SQL } from "bun";
|
|
8
8
|
// import Query from "./Query"; // Lazy import to avoid cycle
|
|
9
9
|
import { timed } from "./Decorators";
|
|
10
10
|
import EntityHookManager from "./EntityHookManager";
|
|
11
|
+
import { getMetadataStorage } from "./metadata";
|
|
11
12
|
import { EntityCreatedEvent, EntityUpdatedEvent, EntityDeletedEvent, ComponentAddedEvent, ComponentUpdatedEvent, ComponentRemovedEvent } from "./events/EntityLifecycleEvents";
|
|
12
13
|
import type { IEntity } from "./EntityInterface";
|
|
13
14
|
|
|
@@ -16,10 +17,14 @@ export class Entity implements IEntity {
|
|
|
16
17
|
public _persisted: boolean = false;
|
|
17
18
|
private components: Map<string, BaseComponent> = new Map<string, BaseComponent>();
|
|
18
19
|
private removedComponents: Set<string> = new Set<string>();
|
|
20
|
+
// Track components that were removed and already saved to DB
|
|
21
|
+
// This persists after save() so resolvers can detect removed components
|
|
22
|
+
private savedRemovedComponents: Set<string> = new Set<string>();
|
|
19
23
|
protected _dirty: boolean = false;
|
|
20
24
|
|
|
21
25
|
constructor(id?: string) {
|
|
22
|
-
|
|
26
|
+
// Use || instead of ?? to also handle empty strings
|
|
27
|
+
this.id = (id && id.trim() !== '') ? id : uuidv7();
|
|
23
28
|
this._dirty = true;
|
|
24
29
|
}
|
|
25
30
|
|
|
@@ -27,6 +32,10 @@ export class Entity implements IEntity {
|
|
|
27
32
|
return new Entity();
|
|
28
33
|
}
|
|
29
34
|
|
|
35
|
+
public static CreateWithId(id: string): Entity {
|
|
36
|
+
return new Entity(id);
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
protected addComponent(component: BaseComponent): Entity {
|
|
31
40
|
this.components.set(component.getTypeID(), component);
|
|
32
41
|
return this;
|
|
@@ -36,6 +45,38 @@ export class Entity implements IEntity {
|
|
|
36
45
|
return Array.from(this.components.values());
|
|
37
46
|
}
|
|
38
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Synchronously check if a component is already loaded in memory.
|
|
50
|
+
* This does NOT trigger a database fetch - use get() for that.
|
|
51
|
+
* @param ctor Component constructor
|
|
52
|
+
* @returns Component instance if already in memory, undefined otherwise
|
|
53
|
+
*/
|
|
54
|
+
public getInMemory<T extends BaseComponent>(ctor: new (...args: any[]) => T): T | undefined {
|
|
55
|
+
return Array.from(this.components.values()).find(comp => comp instanceof ctor) as T | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if a component exists in memory (synchronous, no DB fetch).
|
|
60
|
+
* @param ctor Component constructor
|
|
61
|
+
* @returns true if component is already loaded in memory
|
|
62
|
+
*/
|
|
63
|
+
public hasInMemory<T extends BaseComponent>(ctor: new (...args: any[]) => T): boolean {
|
|
64
|
+
return Array.from(this.components.values()).some(comp => comp instanceof ctor);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check if a component was explicitly removed from this entity (pending or already saved deletion).
|
|
69
|
+
* Useful in resolvers to avoid returning stale cached data for removed components.
|
|
70
|
+
* @param ctor Component constructor
|
|
71
|
+
* @returns true if component was removed (pending or saved)
|
|
72
|
+
*/
|
|
73
|
+
public wasRemoved<T extends BaseComponent>(ctor: new (...args: any[]) => T): boolean {
|
|
74
|
+
const temp = new ctor();
|
|
75
|
+
const typeId = temp.getTypeID();
|
|
76
|
+
// Check both pending removals and already-saved removals
|
|
77
|
+
return this.removedComponents.has(typeId) || this.savedRemovedComponents.has(typeId);
|
|
78
|
+
}
|
|
79
|
+
|
|
39
80
|
/**
|
|
40
81
|
* Adds a new component to the entity.
|
|
41
82
|
* Use like: entity.add(Component, { value: "Test" })
|
|
@@ -66,8 +107,8 @@ export class Entity implements IEntity {
|
|
|
66
107
|
* If it doesn't exist, it adds a new component.
|
|
67
108
|
* Use like: entity.set(Component, { value: "Test" })
|
|
68
109
|
*/
|
|
69
|
-
public async set<T extends BaseComponent>(ctor: new (...args: any[]) => T, data: Partial<ComponentDataType<T
|
|
70
|
-
await this.get(ctor);
|
|
110
|
+
public async set<T extends BaseComponent>(ctor: new (...args: any[]) => T, data: Partial<ComponentDataType<T>>, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL }): Promise<this> {
|
|
111
|
+
await this.get(ctor, context);
|
|
71
112
|
|
|
72
113
|
const component = Array.from(this.components.values()).find(comp => comp instanceof ctor) as T;
|
|
73
114
|
if (component) {
|
|
@@ -86,6 +127,35 @@ export class Entity implements IEntity {
|
|
|
86
127
|
logger.error(`Error firing component updated hook for ${component.getTypeID()}: ${error}`);
|
|
87
128
|
// Don't fail the set operation if hooks fail
|
|
88
129
|
}
|
|
130
|
+
|
|
131
|
+
// Invalidate DataLoader cache if context is provided
|
|
132
|
+
if (context?.loaders?.componentsByEntityType) {
|
|
133
|
+
context.loaders.componentsByEntityType.clear({
|
|
134
|
+
entityId: this.id,
|
|
135
|
+
typeId: component.getTypeID()
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Handle cache operations for component update
|
|
140
|
+
setImmediate(async () => {
|
|
141
|
+
try {
|
|
142
|
+
const { CacheManager } = await import('./cache/CacheManager');
|
|
143
|
+
const cacheManager = CacheManager.getInstance();
|
|
144
|
+
const config = cacheManager.getConfig();
|
|
145
|
+
|
|
146
|
+
if (config.enabled && config.component?.enabled) {
|
|
147
|
+
if (config.strategy === 'write-through') {
|
|
148
|
+
// Write-through: update cache with new component data
|
|
149
|
+
await cacheManager.setComponentWriteThrough(this.id, [component], component.getTypeID(), config.component.ttl);
|
|
150
|
+
} else {
|
|
151
|
+
// Write-invalidate: remove from cache
|
|
152
|
+
await cacheManager.invalidateComponent(this.id, component.getTypeID());
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache operation failed after set', error });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
89
159
|
} else {
|
|
90
160
|
// Add new component
|
|
91
161
|
this.add(ctor, data);
|
|
@@ -102,101 +172,218 @@ export class Entity implements IEntity {
|
|
|
102
172
|
* If you want to keep the component in the database but just remove it from the entity instance,
|
|
103
173
|
* consider implementing a different method.
|
|
104
174
|
*/
|
|
105
|
-
public remove<T extends BaseComponent>(ctor: new (...args: any[]) => T): boolean {
|
|
175
|
+
public remove<T extends BaseComponent>(ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL }): boolean {
|
|
106
176
|
const component = Array.from(this.components.values()).find(comp => comp instanceof ctor) as T;
|
|
107
177
|
|
|
108
178
|
if (component) {
|
|
179
|
+
const typeId = component.getTypeID();
|
|
180
|
+
|
|
109
181
|
// Track the component type for database deletion
|
|
110
|
-
this.removedComponents.add(
|
|
182
|
+
this.removedComponents.add(typeId);
|
|
111
183
|
|
|
112
184
|
// Remove the component from the map
|
|
113
|
-
this.components.delete(
|
|
185
|
+
this.components.delete(typeId);
|
|
114
186
|
this._dirty = true;
|
|
115
187
|
|
|
116
188
|
// Fire component removed event
|
|
117
189
|
try {
|
|
118
190
|
EntityHookManager.executeHooks(new ComponentRemovedEvent(this, component));
|
|
119
191
|
} catch (error) {
|
|
120
|
-
logger.error(`Error firing component removed hook for ${
|
|
192
|
+
logger.error(`Error firing component removed hook for ${typeId}: ${error}`);
|
|
121
193
|
// Don't fail the remove operation if hooks fail
|
|
122
194
|
}
|
|
123
195
|
|
|
196
|
+
// Invalidate DataLoader cache if context is provided
|
|
197
|
+
if (context?.loaders?.componentsByEntityType) {
|
|
198
|
+
context.loaders.componentsByEntityType.clear({
|
|
199
|
+
entityId: this.id,
|
|
200
|
+
typeId: typeId
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Invalidate cache for removed component
|
|
205
|
+
setImmediate(async () => {
|
|
206
|
+
try {
|
|
207
|
+
const { CacheManager } = await import('./cache/CacheManager');
|
|
208
|
+
const cacheManager = CacheManager.getInstance();
|
|
209
|
+
const config = cacheManager.getConfig();
|
|
210
|
+
|
|
211
|
+
if (config.enabled && config.component?.enabled) {
|
|
212
|
+
await cacheManager.invalidateComponent(this.id, typeId);
|
|
213
|
+
}
|
|
214
|
+
} catch (error) {
|
|
215
|
+
logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache invalidation failed after remove', error });
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
124
219
|
return true;
|
|
125
220
|
}
|
|
126
221
|
|
|
127
222
|
return false;
|
|
128
223
|
}
|
|
224
|
+
|
|
129
225
|
/**
|
|
130
|
-
* Get component from
|
|
131
|
-
*
|
|
132
|
-
* @param
|
|
133
|
-
* @returns
|
|
226
|
+
* Get component data from entity. Loads from DB if not cached.
|
|
227
|
+
* @param ctor Component constructor
|
|
228
|
+
* @param context Optional DataLoader context and/or transaction
|
|
229
|
+
* @returns Component data or null
|
|
134
230
|
*/
|
|
135
|
-
public async get<T extends BaseComponent>(ctor: new (...args: any[]) => T): Promise<ComponentDataType<T> | null> {
|
|
136
|
-
const comp =
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
231
|
+
public async get<T extends BaseComponent>(ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL }): Promise<ComponentDataType<T> | null> {
|
|
232
|
+
const comp = await this._loadComponent(ctor, context);
|
|
233
|
+
return comp ? (comp as ComponentGetter<T>).data() : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Check if entity has a component (type guard).
|
|
238
|
+
* Uses in-memory check only - does not query database.
|
|
239
|
+
* Useful for runtime checks before accessing component data.
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* ```typescript
|
|
243
|
+
* if (entity.has(Health)) {
|
|
244
|
+
* // TypeScript knows entity has Health component
|
|
245
|
+
* const health = entity.getCached(Health); // guaranteed to exist
|
|
246
|
+
* }
|
|
247
|
+
* ```
|
|
248
|
+
*
|
|
249
|
+
* @param ctor Component constructor
|
|
250
|
+
* @returns true if component exists in memory
|
|
251
|
+
*/
|
|
252
|
+
public has<T extends BaseComponent>(ctor: new (...args: any[]) => T): boolean {
|
|
253
|
+
return this.hasInMemory(ctor);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Get component data or throw if not found.
|
|
258
|
+
* Use this when you know the component must exist (e.g., after a query that included it).
|
|
259
|
+
*
|
|
260
|
+
* @example
|
|
261
|
+
* ```typescript
|
|
262
|
+
* // After query that included Position
|
|
263
|
+
* const pos = await entity.getOrThrow(Position);
|
|
264
|
+
* // pos is guaranteed to be ComponentDataType<Position>, not null
|
|
265
|
+
* ```
|
|
266
|
+
*
|
|
267
|
+
* @param ctor Component constructor
|
|
268
|
+
* @param context Optional DataLoader context and/or transaction
|
|
269
|
+
* @returns Component data (never null)
|
|
270
|
+
* @throws Error if component not found
|
|
271
|
+
*/
|
|
272
|
+
public async getOrThrow<T extends BaseComponent>(
|
|
273
|
+
ctor: new (...args: any[]) => T,
|
|
274
|
+
context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL }
|
|
275
|
+
): Promise<ComponentDataType<T>> {
|
|
276
|
+
const data = await this.get(ctor, context);
|
|
277
|
+
if (data === null) {
|
|
278
|
+
throw new Error(`Entity ${this.id} is missing required component ${ctor.name}`);
|
|
161
279
|
}
|
|
280
|
+
return data;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Get component data synchronously if already loaded in memory.
|
|
285
|
+
* Does NOT trigger a database fetch - returns undefined if not cached.
|
|
286
|
+
*
|
|
287
|
+
* Use this for performance-critical code paths when you know
|
|
288
|
+
* the component was already loaded (e.g., via query populate).
|
|
289
|
+
*
|
|
290
|
+
* @example
|
|
291
|
+
* ```typescript
|
|
292
|
+
* // After query with .populate()
|
|
293
|
+
* const pos = entity.getCached(Position);
|
|
294
|
+
* if (pos) {
|
|
295
|
+
* console.log(pos.x, pos.y);
|
|
296
|
+
* }
|
|
297
|
+
* ```
|
|
298
|
+
*
|
|
299
|
+
* @param ctor Component constructor
|
|
300
|
+
* @returns Component data if in memory, undefined otherwise
|
|
301
|
+
*/
|
|
302
|
+
public getCached<T extends BaseComponent>(ctor: new (...args: any[]) => T): ComponentDataType<T> | undefined {
|
|
303
|
+
const comp = this.getInMemory(ctor);
|
|
304
|
+
return comp ? (comp as ComponentGetter<T>).data() : undefined;
|
|
162
305
|
}
|
|
163
306
|
|
|
164
307
|
/**
|
|
165
|
-
* Get
|
|
308
|
+
* Get component instance from entity. Loads from DB if not cached.
|
|
166
309
|
* @param ctor Constructor of the component to fetch
|
|
167
|
-
* @
|
|
310
|
+
* @param context Optional DataLoader context and/or transaction
|
|
311
|
+
* @returns Component instance or null
|
|
168
312
|
*/
|
|
169
|
-
public async
|
|
313
|
+
public async getInstanceOf<T extends BaseComponent>(ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL }): Promise<T | null> {
|
|
314
|
+
return this._loadComponent(ctor, context);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private async _loadComponent<T extends BaseComponent>(ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL }): Promise<T | null> {
|
|
170
318
|
const comp = Array.from(this.components.values()).find(comp => comp instanceof ctor) as T | undefined;
|
|
171
|
-
if(typeof comp !== "undefined") {
|
|
319
|
+
if (typeof comp !== "undefined") {
|
|
172
320
|
return comp;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Validate entity ID before database query
|
|
324
|
+
if (!this.id || this.id.trim() === '') {
|
|
325
|
+
logger.warn(`Cannot load component ${ctor.name}: entity id is empty`);
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const temp = new ctor();
|
|
330
|
+
const typeId = temp.getTypeID();
|
|
331
|
+
|
|
332
|
+
// Use transaction if provided, otherwise use default db
|
|
333
|
+
const dbConn = context?.trx ?? db;
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
let componentData: any = null;
|
|
337
|
+
let componentId: string | null = null;
|
|
338
|
+
|
|
339
|
+
if (context?.loaders?.componentsByEntityType) {
|
|
340
|
+
const loaderResult = await context.loaders.componentsByEntityType.load({
|
|
341
|
+
entityId: this.id,
|
|
342
|
+
typeId: typeId
|
|
343
|
+
});
|
|
344
|
+
if (loaderResult) {
|
|
345
|
+
componentData = loaderResult.data;
|
|
346
|
+
componentId = loaderResult.id;
|
|
347
|
+
}
|
|
348
|
+
} else {
|
|
349
|
+
const rows = await dbConn`SELECT id, data FROM components WHERE entity_id = ${this.id} AND type_id = ${typeId} AND deleted_at IS NULL`;
|
|
179
350
|
if (rows.length > 0) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
Object.assign(comp, row.data);
|
|
183
|
-
comp.id = row.id;
|
|
184
|
-
comp.setPersisted(true);
|
|
185
|
-
comp.setDirty(false);
|
|
186
|
-
this.addComponent(comp);
|
|
187
|
-
return comp;
|
|
188
|
-
} else {
|
|
189
|
-
return null;
|
|
351
|
+
componentData = rows[0].data;
|
|
352
|
+
componentId = rows[0].id;
|
|
190
353
|
}
|
|
191
|
-
}
|
|
192
|
-
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (componentData !== null) {
|
|
357
|
+
const comp: any = new ctor();
|
|
358
|
+
if (componentId) {
|
|
359
|
+
comp.id = componentId;
|
|
360
|
+
}
|
|
361
|
+
const parsedData = typeof componentData === 'string' ? JSON.parse(componentData) : componentData;
|
|
362
|
+
Object.assign(comp, parsedData);
|
|
363
|
+
const storage = getMetadataStorage();
|
|
364
|
+
const props = storage.componentProperties.get(typeId);
|
|
365
|
+
if (props) {
|
|
366
|
+
for (const prop of props) {
|
|
367
|
+
if (prop.propertyType === Date && typeof comp[prop.propertyKey] === 'string') {
|
|
368
|
+
comp[prop.propertyKey] = new Date(comp[prop.propertyKey]);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
comp.setPersisted(true);
|
|
373
|
+
comp.setDirty(false);
|
|
374
|
+
this.addComponent(comp);
|
|
375
|
+
return comp as T;
|
|
376
|
+
} else {
|
|
193
377
|
return null;
|
|
194
378
|
}
|
|
379
|
+
} catch (error) {
|
|
380
|
+
logger.error(`Failed to fetch component ${ctor.name}: ${error}`);
|
|
381
|
+
return null;
|
|
195
382
|
}
|
|
196
383
|
}
|
|
197
384
|
|
|
198
385
|
@timed("Entity.save")
|
|
199
|
-
public save() {
|
|
386
|
+
public save(trx?: SQL, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL }) {
|
|
200
387
|
return new Promise<boolean>((resolve, reject) => {
|
|
201
388
|
// Add timeout to prevent hanging
|
|
202
389
|
const timeout = setTimeout(() => {
|
|
@@ -204,41 +391,177 @@ export class Entity implements IEntity {
|
|
|
204
391
|
reject(new Error(`Entity save timeout for entity ${this.id}`));
|
|
205
392
|
}, 30000); // 30 second timeout
|
|
206
393
|
|
|
207
|
-
|
|
208
|
-
|
|
394
|
+
// Capture dirty components BEFORE doSave clears the dirty flags
|
|
395
|
+
const changedComponentTypeIds = this.getDirtyComponents();
|
|
396
|
+
const removedComponentTypeIds = Array.from(this.removedComponents);
|
|
397
|
+
|
|
398
|
+
if (trx) {
|
|
399
|
+
// Use provided transaction
|
|
400
|
+
this.doSave(trx)
|
|
401
|
+
.then(async result => {
|
|
402
|
+
clearTimeout(timeout);
|
|
403
|
+
await this.handleCacheAfterSave(changedComponentTypeIds, removedComponentTypeIds, context);
|
|
404
|
+
resolve(result);
|
|
405
|
+
})
|
|
406
|
+
.catch(error => {
|
|
407
|
+
clearTimeout(timeout);
|
|
408
|
+
reject(error);
|
|
409
|
+
});
|
|
410
|
+
} else {
|
|
411
|
+
// Create new transaction
|
|
412
|
+
db.transaction(async (newTrx) => {
|
|
413
|
+
return await this.doSave(newTrx);
|
|
414
|
+
})
|
|
415
|
+
.then(async result => {
|
|
209
416
|
clearTimeout(timeout);
|
|
417
|
+
await this.handleCacheAfterSave(changedComponentTypeIds, removedComponentTypeIds, context);
|
|
210
418
|
resolve(result);
|
|
211
419
|
})
|
|
212
420
|
.catch(error => {
|
|
213
421
|
clearTimeout(timeout);
|
|
214
422
|
reject(error);
|
|
215
423
|
});
|
|
424
|
+
}
|
|
216
425
|
});
|
|
217
426
|
}
|
|
218
427
|
|
|
219
|
-
|
|
428
|
+
/**
|
|
429
|
+
* Handle cache operations after successful save
|
|
430
|
+
* @param changedComponentTypeIds - Component type IDs that were dirty before save (captured before doSave clears flags)
|
|
431
|
+
* @param removedComponentTypeIds - Component type IDs that were removed (captured before doSave clears the set)
|
|
432
|
+
*/
|
|
433
|
+
private async handleCacheAfterSave(changedComponentTypeIds: string[], removedComponentTypeIds: string[], context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL }): Promise<void> {
|
|
434
|
+
try {
|
|
435
|
+
// Import CacheManager dynamically to avoid circular dependency
|
|
436
|
+
const { CacheManager } = await import('./cache/CacheManager');
|
|
437
|
+
const cacheManager = CacheManager.getInstance();
|
|
438
|
+
const config = cacheManager.getConfig();
|
|
439
|
+
|
|
440
|
+
if (config.enabled && config.entity?.enabled) {
|
|
441
|
+
// Always update entity existence cache
|
|
442
|
+
if (config.strategy === 'write-through') {
|
|
443
|
+
await cacheManager.setEntityWriteThrough(this, config.entity.ttl);
|
|
444
|
+
} else {
|
|
445
|
+
await cacheManager.invalidateEntity(this.id);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Handle component cache invalidation with granular approach
|
|
450
|
+
if (config.enabled && config.component?.enabled) {
|
|
451
|
+
// Use the pre-captured lists instead of re-querying (dirty flags are already cleared by doSave)
|
|
452
|
+
|
|
453
|
+
// Invalidate cache for changed components
|
|
454
|
+
for (const typeId of changedComponentTypeIds) {
|
|
455
|
+
if (config.strategy === 'write-through') {
|
|
456
|
+
// Update component cache with new data
|
|
457
|
+
const component = this.components.get(typeId);
|
|
458
|
+
if (component) {
|
|
459
|
+
await cacheManager.setComponentWriteThrough(this.id, [component], typeId, config.component.ttl);
|
|
460
|
+
}
|
|
461
|
+
} else {
|
|
462
|
+
// Invalidate component cache
|
|
463
|
+
await cacheManager.invalidateComponent(this.id, typeId);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Invalidate DataLoader cache for changed component
|
|
467
|
+
if (context?.loaders?.componentsByEntityType) {
|
|
468
|
+
context.loaders.componentsByEntityType.clear({
|
|
469
|
+
entityId: this.id,
|
|
470
|
+
typeId: typeId
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Invalidate cache for removed components
|
|
476
|
+
for (const typeId of removedComponentTypeIds) {
|
|
477
|
+
await cacheManager.invalidateComponent(this.id, typeId);
|
|
478
|
+
|
|
479
|
+
// Invalidate DataLoader cache for removed component
|
|
480
|
+
if (context?.loaders?.componentsByEntityType) {
|
|
481
|
+
context.loaders.componentsByEntityType.clear({
|
|
482
|
+
entityId: this.id,
|
|
483
|
+
typeId: typeId
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
} catch (error) {
|
|
489
|
+
logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache operation failed after save', error });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
public doSave(trx: SQL) {
|
|
494
|
+
return new Promise<boolean>(async (resolve, reject) => {
|
|
495
|
+
// Validate entity ID to prevent PostgreSQL UUID parsing errors
|
|
496
|
+
if (!this.id || this.id.trim() === '') {
|
|
497
|
+
logger.error(`Cannot save entity: id is empty or invalid`);
|
|
498
|
+
return reject(new Error(`Cannot save entity: id is empty or invalid`));
|
|
499
|
+
}
|
|
220
500
|
|
|
221
|
-
public doSave() {
|
|
222
|
-
return new Promise<boolean>(async resolve => {
|
|
223
501
|
if(!this._dirty) {
|
|
224
|
-
|
|
225
|
-
|
|
502
|
+
let dirtyComponents: string[] = [];
|
|
503
|
+
try {
|
|
504
|
+
dirtyComponents = this.getDirtyComponents();
|
|
505
|
+
} catch {
|
|
506
|
+
// best-effort diagnostics only
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const removedTypeIds = Array.from(this.removedComponents);
|
|
510
|
+
const entityType = (this as any)?.constructor?.name ?? "Entity";
|
|
511
|
+
const dirtyComponentPreview = dirtyComponents.slice(0, 10).map((component) => {
|
|
512
|
+
const anyComponent = component as any;
|
|
513
|
+
return {
|
|
514
|
+
type: anyComponent?.constructor?.name ?? "Component",
|
|
515
|
+
typeId: typeof anyComponent?.getTypeID === "function" ? anyComponent.getTypeID() : undefined,
|
|
516
|
+
id: anyComponent?.id,
|
|
517
|
+
persisted: anyComponent?._persisted,
|
|
518
|
+
dirty: anyComponent?._dirty,
|
|
519
|
+
};
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
logger.trace(
|
|
523
|
+
{
|
|
524
|
+
component: "Entity",
|
|
525
|
+
entity: {
|
|
526
|
+
type: entityType,
|
|
527
|
+
id: this.id,
|
|
528
|
+
persisted: this._persisted,
|
|
529
|
+
dirty: this._dirty,
|
|
530
|
+
},
|
|
531
|
+
components: {
|
|
532
|
+
total: this.components.size,
|
|
533
|
+
dirtyCount: dirtyComponents.length,
|
|
534
|
+
dirtyPreview: dirtyComponentPreview,
|
|
535
|
+
},
|
|
536
|
+
removedComponents: {
|
|
537
|
+
count: removedTypeIds.length,
|
|
538
|
+
typeIdsPreview: removedTypeIds.slice(0, 10),
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
"[Entity.doSave] Skipping save because entity is not dirty"
|
|
542
|
+
);
|
|
543
|
+
return resolve(true);
|
|
226
544
|
}
|
|
227
545
|
|
|
228
546
|
const wasNew = !this._persisted;
|
|
229
547
|
const changedComponents = this.getDirtyComponents();
|
|
230
548
|
|
|
231
|
-
|
|
549
|
+
const executeSave = async (saveTrx: SQL) => {
|
|
232
550
|
if(!this._persisted) {
|
|
233
|
-
await
|
|
551
|
+
await saveTrx`INSERT INTO entities (id) VALUES (${this.id}) ON CONFLICT DO NOTHING`;
|
|
234
552
|
this._persisted = true;
|
|
235
553
|
}
|
|
236
|
-
|
|
554
|
+
|
|
237
555
|
// Delete removed components from database
|
|
238
556
|
if (this.removedComponents.size > 0) {
|
|
239
557
|
const typeIds = Array.from(this.removedComponents);
|
|
240
|
-
await
|
|
241
|
-
await
|
|
558
|
+
await saveTrx`DELETE FROM components WHERE entity_id = ${this.id} AND type_id IN ${sql(typeIds)}`;
|
|
559
|
+
await saveTrx`DELETE FROM entity_components WHERE entity_id = ${this.id} AND type_id IN ${sql(typeIds)}`;
|
|
560
|
+
// Move to savedRemovedComponents so resolvers can still detect removed components
|
|
561
|
+
// This is needed because DataLoader may have stale cached data for this request
|
|
562
|
+
for (const typeId of typeIds) {
|
|
563
|
+
this.savedRemovedComponents.add(typeId);
|
|
564
|
+
}
|
|
242
565
|
this.removedComponents.clear();
|
|
243
566
|
}
|
|
244
567
|
|
|
@@ -246,12 +569,83 @@ export class Entity implements IEntity {
|
|
|
246
569
|
logger.trace(`No components to save for entity ${this.id}`);
|
|
247
570
|
return;
|
|
248
571
|
}
|
|
249
|
-
|
|
572
|
+
|
|
573
|
+
// Batch inserts and updates for better performance
|
|
574
|
+
const componentsToInsert = [];
|
|
575
|
+
const entityComponentsToInsert = [];
|
|
576
|
+
const componentsToUpdate = [];
|
|
577
|
+
|
|
250
578
|
for(const comp of this.components.values()) {
|
|
251
|
-
|
|
579
|
+
const compName = comp.constructor.name;
|
|
580
|
+
if (!ComponentRegistry.isComponentReady(compName)) {
|
|
581
|
+
await ComponentRegistry.getReadyPromise(compName);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if(!(comp as any)._persisted) {
|
|
585
|
+
if(comp.id === "") {
|
|
586
|
+
comp.id = uuidv7();
|
|
587
|
+
}
|
|
588
|
+
componentsToInsert.push({
|
|
589
|
+
id: comp.id,
|
|
590
|
+
entity_id: this.id,
|
|
591
|
+
name: compName,
|
|
592
|
+
type_id: comp.getTypeID(),
|
|
593
|
+
data: comp.serializableData()
|
|
594
|
+
});
|
|
595
|
+
entityComponentsToInsert.push({
|
|
596
|
+
entity_id: this.id,
|
|
597
|
+
type_id: comp.getTypeID(),
|
|
598
|
+
component_id: comp.id
|
|
599
|
+
});
|
|
600
|
+
(comp as any).setPersisted(true);
|
|
601
|
+
(comp as any).setDirty(false);
|
|
602
|
+
} else if((comp as any)._dirty) {
|
|
603
|
+
componentsToUpdate.push({
|
|
604
|
+
id: comp.id,
|
|
605
|
+
data: comp.serializableData()
|
|
606
|
+
});
|
|
607
|
+
(comp as any).setDirty(false);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Perform batch inserts
|
|
612
|
+
if(componentsToInsert.length > 0) {
|
|
613
|
+
await saveTrx`INSERT INTO components ${sql(componentsToInsert, 'id', 'entity_id', 'name', 'type_id', 'data')}`;
|
|
614
|
+
await saveTrx`INSERT INTO entity_components ${sql(entityComponentsToInsert, 'entity_id', 'type_id', 'component_id')} ON CONFLICT DO NOTHING`;
|
|
252
615
|
}
|
|
253
|
-
|
|
254
|
-
|
|
616
|
+
|
|
617
|
+
// Insert entity_components for existing components if entity is new
|
|
618
|
+
if(!this._persisted) {
|
|
619
|
+
const existingEntityComponents = [];
|
|
620
|
+
for(const comp of this.components.values()) {
|
|
621
|
+
if((comp as any)._persisted) {
|
|
622
|
+
existingEntityComponents.push({
|
|
623
|
+
entity_id: this.id,
|
|
624
|
+
type_id: comp.getTypeID(),
|
|
625
|
+
component_id: comp.id
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if(existingEntityComponents.length > 0) {
|
|
630
|
+
await saveTrx`INSERT INTO entity_components ${sql(existingEntityComponents, 'entity_id', 'type_id', 'component_id')} ON CONFLICT DO NOTHING`;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Perform batch updates
|
|
635
|
+
if(componentsToUpdate.length > 0) {
|
|
636
|
+
for(const comp of componentsToUpdate) {
|
|
637
|
+
// Validate component ID to prevent PostgreSQL UUID parsing errors
|
|
638
|
+
if (!comp.id || comp.id.trim() === '') {
|
|
639
|
+
logger.error(`Cannot update component: id is empty or invalid. Component data: ${JSON.stringify(comp.data).substring(0, 200)}`);
|
|
640
|
+
throw new Error(`Cannot update component: component id is empty or invalid`);
|
|
641
|
+
}
|
|
642
|
+
logger.trace({ componentId: comp.id, data: comp.data }, `[Entity.doSave] Updating component`);
|
|
643
|
+
await saveTrx`UPDATE components SET data = ${comp.data} WHERE id = ${comp.id}`;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
await executeSave(trx);
|
|
255
649
|
|
|
256
650
|
this._dirty = false;
|
|
257
651
|
|
|
@@ -303,6 +697,22 @@ export class Entity implements IEntity {
|
|
|
303
697
|
// Don't fail the delete operation if hooks fail
|
|
304
698
|
}
|
|
305
699
|
|
|
700
|
+
// Invalidate cache after successful deletion
|
|
701
|
+
try {
|
|
702
|
+
const { CacheManager } = await import('./cache/CacheManager');
|
|
703
|
+
const cacheManager = CacheManager.getInstance();
|
|
704
|
+
const config = cacheManager.getConfig();
|
|
705
|
+
|
|
706
|
+
if (config.enabled && config.entity?.enabled) {
|
|
707
|
+
await cacheManager.invalidateEntity(this.id);
|
|
708
|
+
}
|
|
709
|
+
if (config.enabled && config.component?.enabled) {
|
|
710
|
+
await cacheManager.invalidateAllEntityComponents(this.id);
|
|
711
|
+
}
|
|
712
|
+
} catch (error) {
|
|
713
|
+
logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache invalidation failed after delete', error });
|
|
714
|
+
}
|
|
715
|
+
|
|
306
716
|
resolve(true);
|
|
307
717
|
} catch (error) {
|
|
308
718
|
logger.error(`Failed to delete entity: ${error}`);
|
|
@@ -325,7 +735,9 @@ export class Entity implements IEntity {
|
|
|
325
735
|
private getDirtyComponents(): string[] {
|
|
326
736
|
const dirtyComponents: string[] = [];
|
|
327
737
|
for (const component of this.components.values()) {
|
|
328
|
-
|
|
738
|
+
// Include both dirty (modified) components AND new (not persisted) components
|
|
739
|
+
// New components need to be cached after save, not just modified ones
|
|
740
|
+
if ((component as any)._dirty || !(component as any)._persisted) {
|
|
329
741
|
dirtyComponents.push(component.getTypeID());
|
|
330
742
|
}
|
|
331
743
|
}
|
|
@@ -336,16 +748,23 @@ export class Entity implements IEntity {
|
|
|
336
748
|
@timed("Entity.LoadMultiple")
|
|
337
749
|
public static async LoadMultiple(ids: string[]): Promise<Entity[]> {
|
|
338
750
|
if (ids.length === 0) return [];
|
|
751
|
+
|
|
752
|
+
// Filter out empty/invalid IDs to prevent PostgreSQL UUID parsing errors
|
|
753
|
+
const validIds = ids.filter(id => id && id.trim() !== '');
|
|
754
|
+
if (validIds.length === 0) return [];
|
|
755
|
+
if (validIds.length !== ids.length) {
|
|
756
|
+
logger.warn(`LoadMultiple: Filtered out ${ids.length - validIds.length} invalid entity IDs`);
|
|
757
|
+
}
|
|
339
758
|
|
|
340
759
|
const components = await db`
|
|
341
760
|
SELECT c.id, c.entity_id, c.type_id, c.data
|
|
342
761
|
FROM components c
|
|
343
|
-
WHERE c.entity_id IN ${sql(
|
|
762
|
+
WHERE c.entity_id IN ${sql(validIds)} AND c.deleted_at IS NULL
|
|
344
763
|
`;
|
|
345
764
|
|
|
346
765
|
const entitiesMap = new Map<string, Entity>();
|
|
347
766
|
|
|
348
|
-
for (const id of
|
|
767
|
+
for (const id of validIds) {
|
|
349
768
|
const entity = new Entity();
|
|
350
769
|
entity.id = id;
|
|
351
770
|
entity.setPersisted(true);
|
|
@@ -358,7 +777,8 @@ export class Entity implements IEntity {
|
|
|
358
777
|
const ctor = ComponentRegistry.getConstructor(type_id);
|
|
359
778
|
if (ctor) {
|
|
360
779
|
const comp = new ctor();
|
|
361
|
-
|
|
780
|
+
const componentData = typeof data === 'string' ? JSON.parse(data) : data;
|
|
781
|
+
Object.assign(comp, componentData);
|
|
362
782
|
comp.id = id;
|
|
363
783
|
comp.setPersisted(true);
|
|
364
784
|
comp.setDirty(false);
|
|
@@ -369,10 +789,14 @@ export class Entity implements IEntity {
|
|
|
369
789
|
return Array.from(entitiesMap.values());
|
|
370
790
|
}
|
|
371
791
|
|
|
372
|
-
public static async LoadComponents(entities: Entity[], componentIds: string[]): Promise<void> {
|
|
792
|
+
public static async LoadComponents(entities: Entity[], componentIds: string[], skipCache: boolean = false): Promise<void> {
|
|
373
793
|
if (entities.length === 0 || componentIds.length === 0) return;
|
|
374
794
|
|
|
375
|
-
|
|
795
|
+
// Filter out entities with empty/invalid IDs to prevent PostgreSQL UUID parsing errors
|
|
796
|
+
const validEntities = entities.filter(e => e.id && e.id.trim() !== '');
|
|
797
|
+
if (validEntities.length === 0) return;
|
|
798
|
+
|
|
799
|
+
const entityIds = validEntities.map(e => e.id);
|
|
376
800
|
|
|
377
801
|
const components = await db`
|
|
378
802
|
SELECT c.id, c.entity_id, c.type_id, c.data
|
|
@@ -380,14 +804,18 @@ export class Entity implements IEntity {
|
|
|
380
804
|
WHERE c.entity_id IN ${sql(entityIds)} AND c.type_id IN ${sql(componentIds)} AND c.deleted_at IS NULL
|
|
381
805
|
`;
|
|
382
806
|
|
|
807
|
+
// Use Map for O(1) lookups instead of O(n) find() - fixes O(n²) performance issue
|
|
808
|
+
const entityMap = new Map<string, Entity>(validEntities.map(e => [e.id, e]));
|
|
809
|
+
|
|
383
810
|
for (const row of components) {
|
|
384
811
|
const { id, entity_id, type_id, data } = row;
|
|
385
|
-
const entity =
|
|
812
|
+
const entity = entityMap.get(entity_id); // O(1) instead of O(n)
|
|
386
813
|
if (entity) {
|
|
387
814
|
const ctor = ComponentRegistry.getConstructor(type_id);
|
|
388
815
|
if (ctor) {
|
|
389
816
|
const comp = new ctor();
|
|
390
|
-
|
|
817
|
+
const componentData = typeof data === 'string' ? JSON.parse(data) : data;
|
|
818
|
+
Object.assign(comp, componentData);
|
|
391
819
|
comp.id = id;
|
|
392
820
|
comp.setPersisted(true);
|
|
393
821
|
comp.setDirty(false);
|
|
@@ -402,14 +830,115 @@ export class Entity implements IEntity {
|
|
|
402
830
|
* @param id Entity ID
|
|
403
831
|
* @returns Entity | null
|
|
404
832
|
*/
|
|
405
|
-
public static async FindById(id: string): Promise<Entity | null> {
|
|
406
|
-
|
|
407
|
-
|
|
833
|
+
public static async FindById(id: string, trx?: SQL): Promise<Entity | null> {
|
|
834
|
+
// Validate ID to prevent PostgreSQL UUID parsing errors
|
|
835
|
+
if (!id || typeof id !== 'string' || id.trim() === '') {
|
|
836
|
+
logger.warn(`FindById called with invalid id: "${id}"`);
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
const { Query } = await import("../query/Query");
|
|
840
|
+
const entities = await new Query(trx).findById(id).populate().exec()
|
|
408
841
|
if(entities.length === 1) {
|
|
409
842
|
return entities[0]!;
|
|
410
843
|
}
|
|
411
844
|
return null;
|
|
412
845
|
}
|
|
846
|
+
|
|
847
|
+
public static Clone(entity: Entity): Entity {
|
|
848
|
+
const clone = new Entity();
|
|
849
|
+
clone._dirty = true;
|
|
850
|
+
clone._persisted = false;
|
|
851
|
+
for (const comp of entity.components.values()) {
|
|
852
|
+
const newComp = new (comp.constructor as any)();
|
|
853
|
+
Object.assign(newComp, comp.data());
|
|
854
|
+
newComp.id = uuidv7();
|
|
855
|
+
newComp.setDirty(true);
|
|
856
|
+
newComp.setPersisted(false);
|
|
857
|
+
clone.addComponent(newComp);
|
|
858
|
+
}
|
|
859
|
+
return clone;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
public static MakeRef(entity: Entity): Entity {
|
|
863
|
+
const ref = new Entity();
|
|
864
|
+
ref._dirty = true;
|
|
865
|
+
ref._persisted = false;
|
|
866
|
+
for (const comp of entity.components.values()) {
|
|
867
|
+
const refComp = comp;
|
|
868
|
+
refComp.setDirty(false);
|
|
869
|
+
refComp.setPersisted(true);
|
|
870
|
+
ref.addComponent(refComp);
|
|
871
|
+
}
|
|
872
|
+
return ref;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Serialize the entity with only the currently loaded components
|
|
877
|
+
* @returns Object containing id and components data
|
|
878
|
+
*/
|
|
879
|
+
public serialize(): { id: string; components: Record<string, any> } {
|
|
880
|
+
const components: Record<string, any> = {};
|
|
881
|
+
for (const comp of this.components.values()) {
|
|
882
|
+
components[comp.constructor.name] = comp.serializableData();
|
|
883
|
+
}
|
|
884
|
+
return {
|
|
885
|
+
id: this.id,
|
|
886
|
+
components
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Deserialize/reconstitute an Entity from cached/serialized data.
|
|
892
|
+
* Handles both serialized format { id, components } and raw Entity-like objects.
|
|
893
|
+
* @param data Serialized entity data or Entity-like plain object
|
|
894
|
+
* @returns Reconstituted Entity instance
|
|
895
|
+
*/
|
|
896
|
+
public static deserialize(data: any): Entity {
|
|
897
|
+
if (data instanceof Entity) {
|
|
898
|
+
return data;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const entity = new Entity(data.id);
|
|
902
|
+
entity._persisted = true;
|
|
903
|
+
entity._dirty = false;
|
|
904
|
+
|
|
905
|
+
// Handle serialized format: { id, components: { ComponentName: {...data} } }
|
|
906
|
+
if (data.components && typeof data.components === 'object') {
|
|
907
|
+
const storage = getMetadataStorage();
|
|
908
|
+
|
|
909
|
+
for (const [componentName, componentData] of Object.entries(data.components)) {
|
|
910
|
+
// Find the component constructor by name
|
|
911
|
+
const ComponentCtor = ComponentRegistry.getConstructorByName(componentName);
|
|
912
|
+
if (!ComponentCtor) {
|
|
913
|
+
logger.warn(`Cannot deserialize component: constructor not found for ${componentName}`);
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const comp = new ComponentCtor();
|
|
918
|
+
const parsedData = typeof componentData === 'string' ? JSON.parse(componentData) : componentData;
|
|
919
|
+
Object.assign(comp, parsedData);
|
|
920
|
+
|
|
921
|
+
// Restore Date objects
|
|
922
|
+
const typeId = comp.getTypeID();
|
|
923
|
+
const props = storage.componentProperties.get(typeId);
|
|
924
|
+
if (props) {
|
|
925
|
+
for (const prop of props) {
|
|
926
|
+
if (prop.propertyType === Date && typeof (comp as any)[prop.propertyKey] === 'string') {
|
|
927
|
+
(comp as any)[prop.propertyKey] = new Date((comp as any)[prop.propertyKey]);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
comp.setPersisted(true);
|
|
933
|
+
comp.setDirty(false);
|
|
934
|
+
entity.addComponent(comp);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
return entity;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
|
|
413
942
|
}
|
|
414
943
|
|
|
415
944
|
export default Entity;
|