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.
Files changed (257) hide show
  1. package/.claude/settings.local.json +47 -0
  2. package/.claude/skills/update-memory.md +74 -0
  3. package/.prettierrc +4 -0
  4. package/.serena/memories/architectural-decision-no-dependency-injection.md +76 -0
  5. package/.serena/memories/architecture.md +154 -0
  6. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +165 -0
  7. package/.serena/memories/code_style_and_conventions.md +76 -0
  8. package/.serena/memories/project_overview.md +43 -0
  9. package/.serena/memories/schema-dsl-plan.md +107 -0
  10. package/.serena/memories/suggested_commands.md +80 -0
  11. package/.serena/memories/typescript-compilation-status.md +54 -0
  12. package/.serena/project.yml +114 -0
  13. package/TODO.md +1 -7
  14. package/bun.lock +150 -4
  15. package/bunfig.toml +10 -0
  16. package/config/cache.config.ts +77 -0
  17. package/config/upload.config.ts +4 -5
  18. package/core/App.ts +870 -123
  19. package/core/ArcheType.ts +2268 -377
  20. package/core/BatchLoader.ts +181 -71
  21. package/core/Config.ts +153 -0
  22. package/core/Decorators.ts +4 -1
  23. package/core/Entity.ts +621 -92
  24. package/core/EntityHookManager.ts +1 -1
  25. package/core/EntityInterface.ts +3 -1
  26. package/core/EntityManager.ts +1 -13
  27. package/core/ErrorHandler.ts +8 -2
  28. package/core/Logger.ts +9 -0
  29. package/core/Middleware.ts +34 -0
  30. package/core/RequestContext.ts +5 -1
  31. package/core/RequestLoaders.ts +227 -93
  32. package/core/SchedulerManager.ts +193 -52
  33. package/core/cache/CacheAnalytics.ts +399 -0
  34. package/core/cache/CacheFactory.ts +145 -0
  35. package/core/cache/CacheManager.ts +520 -0
  36. package/core/cache/CacheProvider.ts +34 -0
  37. package/core/cache/CacheWarmer.ts +157 -0
  38. package/core/cache/CompressionUtils.ts +110 -0
  39. package/core/cache/MemoryCache.ts +251 -0
  40. package/core/cache/MultiLevelCache.ts +180 -0
  41. package/core/cache/NoOpCache.ts +53 -0
  42. package/core/cache/RedisCache.ts +464 -0
  43. package/core/cache/TTLStrategy.ts +254 -0
  44. package/core/cache/index.ts +6 -0
  45. package/core/components/BaseComponent.ts +120 -0
  46. package/core/{ComponentRegistry.ts → components/ComponentRegistry.ts} +148 -54
  47. package/core/components/Decorators.ts +88 -0
  48. package/core/components/Interfaces.ts +7 -0
  49. package/core/components/index.ts +5 -0
  50. package/core/decorators/EntityHooks.ts +0 -3
  51. package/core/decorators/IndexedField.ts +26 -0
  52. package/core/decorators/ScheduledTask.ts +0 -47
  53. package/core/events/EntityLifecycleEvents.ts +1 -1
  54. package/core/health.ts +112 -0
  55. package/core/metadata/definitions/ArcheType.ts +14 -0
  56. package/core/metadata/definitions/Component.ts +9 -0
  57. package/core/metadata/definitions/gqlObject.ts +1 -1
  58. package/core/metadata/index.ts +42 -1
  59. package/core/metadata/metadata-storage.ts +28 -2
  60. package/core/middleware/AccessLog.ts +59 -0
  61. package/core/middleware/RequestId.ts +38 -0
  62. package/core/middleware/SecurityHeaders.ts +62 -0
  63. package/core/middleware/index.ts +3 -0
  64. package/core/scheduler/DistributedLock.ts +266 -0
  65. package/core/scheduler/index.ts +15 -0
  66. package/core/validateEnv.ts +92 -0
  67. package/database/DatabaseHelper.ts +416 -40
  68. package/database/IndexingStrategy.ts +342 -0
  69. package/database/PreparedStatementCache.ts +226 -0
  70. package/database/index.ts +32 -7
  71. package/database/sqlHelpers.ts +14 -2
  72. package/endpoints/archetypes.ts +362 -0
  73. package/endpoints/components.ts +58 -0
  74. package/endpoints/entity.ts +80 -0
  75. package/endpoints/index.ts +27 -0
  76. package/endpoints/query.ts +93 -0
  77. package/endpoints/stats.ts +76 -0
  78. package/endpoints/tables.ts +212 -0
  79. package/endpoints/types.ts +155 -0
  80. package/gql/ArchetypeOperations.ts +32 -86
  81. package/gql/Generator.ts +27 -315
  82. package/gql/GeneratorV2.ts +37 -0
  83. package/gql/builders/InputTypeBuilder.ts +99 -0
  84. package/gql/builders/ResolverBuilder.ts +234 -0
  85. package/gql/builders/TypeDefBuilder.ts +105 -0
  86. package/gql/builders/index.ts +3 -0
  87. package/gql/decorators/Upload.ts +1 -1
  88. package/gql/depthLimit.ts +85 -0
  89. package/gql/graph/GraphNode.ts +224 -0
  90. package/gql/graph/SchemaGraph.ts +278 -0
  91. package/gql/helpers.ts +8 -2
  92. package/gql/index.ts +56 -4
  93. package/gql/middleware.ts +79 -0
  94. package/gql/orchestration/GraphQLSchemaOrchestrator.ts +241 -0
  95. package/gql/orchestration/index.ts +1 -0
  96. package/gql/scanner/ServiceScanner.ts +347 -0
  97. package/gql/schema/index.ts +458 -0
  98. package/gql/strategies/TypeGenerationStrategy.ts +329 -0
  99. package/gql/types.ts +1 -0
  100. package/gql/utils/TypeSignature.ts +220 -0
  101. package/gql/utils/index.ts +1 -0
  102. package/gql/visitors/ArchetypePreprocessorVisitor.ts +80 -0
  103. package/gql/visitors/DeduplicationVisitor.ts +82 -0
  104. package/gql/visitors/GraphVisitor.ts +78 -0
  105. package/gql/visitors/ResolverGeneratorVisitor.ts +122 -0
  106. package/gql/visitors/SchemaGeneratorVisitor.ts +851 -0
  107. package/gql/visitors/TypeCollectorVisitor.ts +79 -0
  108. package/gql/visitors/VisitorComposer.ts +96 -0
  109. package/gql/visitors/index.ts +7 -0
  110. package/package.json +59 -37
  111. package/plugins/index.ts +2 -2
  112. package/query/CTENode.ts +97 -0
  113. package/query/ComponentInclusionNode.ts +689 -0
  114. package/query/FilterBuilder.ts +127 -0
  115. package/query/FilterBuilderRegistry.ts +202 -0
  116. package/query/OrNode.ts +517 -0
  117. package/query/OrQuery.ts +42 -0
  118. package/query/Query.ts +1022 -0
  119. package/query/QueryContext.ts +170 -0
  120. package/query/QueryDAG.ts +122 -0
  121. package/query/QueryNode.ts +65 -0
  122. package/query/SourceNode.ts +53 -0
  123. package/query/builders/FullTextSearchBuilder.ts +236 -0
  124. package/query/index.ts +21 -0
  125. package/scheduler/index.ts +40 -8
  126. package/service/Service.ts +2 -1
  127. package/service/ServiceRegistry.ts +6 -5
  128. package/{core/storage → storage}/LocalStorageProvider.ts +2 -2
  129. package/storage/S3StorageProvider.ts +316 -0
  130. package/{core/storage → storage}/StorageProvider.ts +7 -3
  131. package/studio/bun.lock +482 -0
  132. package/studio/index.html +13 -0
  133. package/studio/package.json +39 -0
  134. package/studio/postcss.config.js +6 -0
  135. package/studio/src/components/DataTable.tsx +211 -0
  136. package/studio/src/components/Layout.tsx +13 -0
  137. package/studio/src/components/PageContainer.tsx +9 -0
  138. package/studio/src/components/PageHeader.tsx +13 -0
  139. package/studio/src/components/SearchBar.tsx +57 -0
  140. package/studio/src/components/Sidebar.tsx +294 -0
  141. package/studio/src/components/ui/button.tsx +56 -0
  142. package/studio/src/components/ui/checkbox.tsx +26 -0
  143. package/studio/src/components/ui/input.tsx +25 -0
  144. package/studio/src/hooks/useDataTable.ts +131 -0
  145. package/studio/src/index.css +36 -0
  146. package/studio/src/lib/api.ts +186 -0
  147. package/studio/src/lib/utils.ts +13 -0
  148. package/studio/src/main.tsx +17 -0
  149. package/studio/src/pages/ArcheType.tsx +239 -0
  150. package/studio/src/pages/Components.tsx +124 -0
  151. package/studio/src/pages/EntityInspector.tsx +302 -0
  152. package/studio/src/pages/QueryRunner.tsx +246 -0
  153. package/studio/src/pages/Table.tsx +94 -0
  154. package/studio/src/pages/Welcome.tsx +241 -0
  155. package/studio/src/routes.tsx +45 -0
  156. package/studio/src/store/archeTypeSettings.ts +30 -0
  157. package/studio/src/store/studio.ts +65 -0
  158. package/studio/src/utils/columnHelpers.tsx +114 -0
  159. package/studio/studio-instructions.md +81 -0
  160. package/studio/tailwind.config.js +77 -0
  161. package/studio/tsconfig.json +24 -0
  162. package/studio/utils.ts +54 -0
  163. package/studio/vite.config.js +19 -0
  164. package/swagger/generator.ts +1 -1
  165. package/tests/e2e/http.test.ts +126 -0
  166. package/tests/fixtures/archetypes/TestUserArchetype.ts +21 -0
  167. package/tests/fixtures/components/TestOrder.ts +23 -0
  168. package/tests/fixtures/components/TestProduct.ts +23 -0
  169. package/tests/fixtures/components/TestUser.ts +20 -0
  170. package/tests/fixtures/components/index.ts +6 -0
  171. package/tests/graphql/SchemaGeneration.test.ts +90 -0
  172. package/tests/graphql/builders/ResolverBuilder.test.ts +223 -0
  173. package/tests/graphql/builders/TypeDefBuilder.test.ts +153 -0
  174. package/tests/integration/archetype/ArcheType.persistence.test.ts +241 -0
  175. package/tests/integration/cache/CacheInvalidation.test.ts +259 -0
  176. package/tests/integration/entity/Entity.persistence.test.ts +333 -0
  177. package/tests/integration/query/Query.exec.test.ts +523 -0
  178. package/tests/pglite-setup.ts +61 -0
  179. package/tests/setup.ts +164 -0
  180. package/tests/stress/BenchmarkRunner.ts +203 -0
  181. package/tests/stress/DataSeeder.ts +190 -0
  182. package/tests/stress/StressTestReporter.ts +229 -0
  183. package/tests/stress/cursor-perf-test.ts +171 -0
  184. package/tests/stress/fixtures/StressTestComponents.ts +58 -0
  185. package/tests/stress/index.ts +7 -0
  186. package/tests/stress/scenarios/query-benchmarks.test.ts +285 -0
  187. package/tests/unit/BatchLoader.test.ts +82 -0
  188. package/tests/unit/archetype/ArcheType.test.ts +107 -0
  189. package/tests/unit/cache/CacheManager.test.ts +347 -0
  190. package/tests/unit/cache/MemoryCache.test.ts +260 -0
  191. package/tests/unit/cache/RedisCache.test.ts +411 -0
  192. package/tests/unit/entity/Entity.components.test.ts +244 -0
  193. package/tests/unit/entity/Entity.test.ts +345 -0
  194. package/tests/unit/gql/depthLimit.test.ts +203 -0
  195. package/tests/unit/gql/operationMiddleware.test.ts +293 -0
  196. package/tests/unit/health/Health.test.ts +129 -0
  197. package/tests/unit/middleware/AccessLog.test.ts +37 -0
  198. package/tests/unit/middleware/Middleware.test.ts +98 -0
  199. package/tests/unit/middleware/RequestId.test.ts +54 -0
  200. package/tests/unit/middleware/SecurityHeaders.test.ts +66 -0
  201. package/tests/unit/query/FilterBuilder.test.ts +111 -0
  202. package/tests/unit/query/Query.test.ts +308 -0
  203. package/tests/unit/scheduler/DistributedLock.test.ts +274 -0
  204. package/tests/unit/schema/schema-integration.test.ts +426 -0
  205. package/tests/unit/schema/schema.test.ts +580 -0
  206. package/tests/unit/storage/S3StorageProvider.test.ts +571 -0
  207. package/tests/unit/upload/RestUpload.test.ts +267 -0
  208. package/tests/unit/validateEnv.test.ts +82 -0
  209. package/tests/utils/entity-tracker.ts +57 -0
  210. package/tests/utils/index.ts +13 -0
  211. package/tests/utils/test-context.ts +149 -0
  212. package/tsconfig.json +5 -1
  213. package/types/archetype.types.ts +6 -0
  214. package/types/hooks.types.ts +1 -1
  215. package/types/query.types.ts +110 -0
  216. package/types/scheduler.types.ts +68 -7
  217. package/types/upload.types.ts +1 -0
  218. package/{core → upload}/FileValidator.ts +10 -1
  219. package/upload/RestUpload.ts +130 -0
  220. package/{core/components → upload}/UploadComponent.ts +11 -11
  221. package/{core → upload}/UploadManager.ts +3 -3
  222. package/upload/index.ts +23 -7
  223. package/utils/UploadHelper.ts +27 -6
  224. package/utils/cronParser.ts +16 -6
  225. package/.github/workflows/deploy-docs.yml +0 -57
  226. package/core/Components.ts +0 -202
  227. package/core/EntityCache.ts +0 -15
  228. package/core/Query.ts +0 -880
  229. package/docs/README.md +0 -149
  230. package/docs/_coverpage.md +0 -36
  231. package/docs/_sidebar.md +0 -23
  232. package/docs/api/core.md +0 -568
  233. package/docs/api/hooks.md +0 -554
  234. package/docs/api/index.md +0 -222
  235. package/docs/api/query.md +0 -678
  236. package/docs/api/service.md +0 -744
  237. package/docs/core-concepts/archetypes.md +0 -512
  238. package/docs/core-concepts/components.md +0 -498
  239. package/docs/core-concepts/entity.md +0 -314
  240. package/docs/core-concepts/hooks.md +0 -683
  241. package/docs/core-concepts/query.md +0 -588
  242. package/docs/core-concepts/services.md +0 -647
  243. package/docs/examples/code-examples.md +0 -425
  244. package/docs/getting-started.md +0 -337
  245. package/docs/index.html +0 -97
  246. package/tests/bench/insert.bench.ts +0 -60
  247. package/tests/bench/relations.bench.ts +0 -270
  248. package/tests/bench/sorting.bench.ts +0 -416
  249. package/tests/component-hooks-simple.test.ts +0 -117
  250. package/tests/component-hooks.test.ts +0 -1461
  251. package/tests/component.test.ts +0 -339
  252. package/tests/errorHandling.test.ts +0 -155
  253. package/tests/hooks.test.ts +0 -667
  254. package/tests/query-sorting.test.ts +0 -101
  255. package/tests/query.test.ts +0 -81
  256. package/tests/relations.test.ts +0 -170
  257. package/tests/scheduler.test.ts +0 -724
package/core/Entity.ts CHANGED
@@ -1,13 +1,14 @@
1
- import type { ComponentDataType, ComponentGetter, BaseComponent } from "./Components";
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
- this.id = id ?? uuidv7();
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>>): Promise<this> {
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(component.getTypeID());
182
+ this.removedComponents.add(typeId);
111
183
 
112
184
  // Remove the component from the map
113
- this.components.delete(component.getTypeID());
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 ${component.getTypeID()}: ${error}`);
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 entities. If entity is populated in query the component will get within the entitiy
131
- * If not it will fetch from database
132
- * @param Component
133
- * @returns `Component | null` *if entity doesn't have the component
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 = Array.from(this.components.values()).find(comp => comp instanceof ctor) as ComponentGetter<T> | undefined;
137
- if(typeof comp !== "undefined") {
138
- return comp.data();
139
- } else {
140
- // fetch from db
141
- const temp = new ctor();
142
- const typeId = temp.getTypeID();
143
- try {
144
- const rows = await db`SELECT id, data FROM components WHERE entity_id = ${this.id} AND type_id = ${typeId} AND deleted_at IS NULL`;
145
- if (rows.length > 0) {
146
- const row = rows[0];
147
- const comp = new ctor();
148
- Object.assign(comp, row.data);
149
- comp.id = row.id;
150
- comp.setPersisted(true);
151
- comp.setDirty(false);
152
- this.addComponent(comp);
153
- return comp.data();
154
- } else {
155
- return null;
156
- }
157
- } catch (error) {
158
- logger.error(`Failed to fetch component: ${error}`);
159
- return null;
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 a component from the entity.
308
+ * Get component instance from entity. Loads from DB if not cached.
166
309
  * @param ctor Constructor of the component to fetch
167
- * @returns Component instance or null if not found
310
+ * @param context Optional DataLoader context and/or transaction
311
+ * @returns Component instance or null
168
312
  */
169
- public async getComponent<T extends BaseComponent>(ctor: new (...args: any[]) => T): Promise<T | null> {
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
- } else {
174
- // fetch from db
175
- const temp = new ctor();
176
- const typeId = temp.getTypeID();
177
- try {
178
- const rows = await db`SELECT id, data FROM components WHERE entity_id = ${this.id} AND type_id = ${typeId} AND deleted_at IS NULL`;
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
- const row = rows[0];
181
- const comp = new ctor();
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
- } catch (error) {
192
- logger.error(`Failed to fetch component: ${error}`);
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
- this.doSave()
208
- .then(result => {
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
- logger.trace("Entity is not dirty, no need to save.");
225
- return resolve(true);
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
- await db.transaction(async (trx) => {
549
+ const executeSave = async (saveTrx: SQL) => {
232
550
  if(!this._persisted) {
233
- await trx`INSERT INTO entities (id) VALUES (${this.id}) ON CONFLICT DO NOTHING`;
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 trx`DELETE FROM components WHERE entity_id = ${this.id} AND type_id IN ${sql(typeIds)}`;
241
- await trx`DELETE FROM entity_components WHERE entity_id = ${this.id} AND type_id IN ${sql(typeIds)}`;
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
- const waitable = [];
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
- waitable.push(comp.save(trx, this.id));
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
- await Promise.all(waitable);
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
- if ((component as any)._dirty) {
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(ids)} AND c.deleted_at IS NULL
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 ids) {
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
- Object.assign(comp, data);
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
- const entityIds = entities.map(e => e.id);
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 = entities.find(e => e.id === entity_id);
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
- Object.assign(comp, data);
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
- const { default: Query } = await import("./Query");
407
- const entities = await new Query().findById(id).populate().exec()
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;