bunsane 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/CHANGELOG.md +445 -318
  2. package/config/cache.config.ts +35 -1
  3. package/core/App.ts +24 -1064
  4. package/core/ArcheType.ts +78 -2110
  5. package/core/BatchLoader.ts +56 -32
  6. package/core/Entity.ts +85 -1043
  7. package/core/EntityHookManager.ts +52 -754
  8. package/core/Logger.ts +10 -0
  9. package/core/RequestContext.ts +64 -6
  10. package/core/RequestLoaders.ts +187 -36
  11. package/core/SchedulerManager.ts +28 -600
  12. package/core/app/bootstrap.ts +133 -0
  13. package/core/app/cors.ts +85 -0
  14. package/core/app/graphqlSetup.ts +56 -0
  15. package/core/app/healthEndpoints.ts +31 -0
  16. package/core/app/metricsCollector.ts +27 -0
  17. package/core/app/preparedStatementWarmup.ts +15 -0
  18. package/core/app/processHandlers.ts +43 -0
  19. package/core/app/requestRouter.ts +310 -0
  20. package/core/app/restRegistry.ts +80 -0
  21. package/core/app/shutdown.ts +97 -0
  22. package/core/app/studioRouter.ts +83 -0
  23. package/core/archetype/customTypes.ts +100 -0
  24. package/core/archetype/decorators.ts +171 -0
  25. package/core/archetype/fieldResolvers.ts +666 -0
  26. package/core/archetype/helpers.ts +29 -0
  27. package/core/archetype/relationLoader.ts +161 -0
  28. package/core/archetype/schemaBuilder.ts +141 -0
  29. package/core/archetype/weaver.ts +218 -0
  30. package/core/archetype/zodSchemaBuilder.ts +527 -0
  31. package/core/cache/CacheManager.ts +173 -267
  32. package/core/cache/CompressionUtils.ts +34 -3
  33. package/core/cache/MemoryCache.ts +40 -37
  34. package/core/cache/RedisCache.ts +4 -4
  35. package/core/cache/health.ts +30 -0
  36. package/core/cache/invalidation.ts +96 -0
  37. package/core/cache/strategies/writeInvalidate.ts +111 -0
  38. package/core/cache/strategies/writeThrough.ts +233 -0
  39. package/core/components/BaseComponent.ts +16 -8
  40. package/core/components/ComponentRegistry.ts +28 -0
  41. package/core/decorators/IndexedField.ts +1 -1
  42. package/core/entity/cacheStrategies.ts +97 -0
  43. package/core/entity/componentAccess.ts +364 -0
  44. package/core/entity/finders.ts +202 -0
  45. package/core/entity/pendingOps.ts +72 -0
  46. package/core/entity/saveEntity.ts +377 -0
  47. package/core/hooks/dispatcher.ts +439 -0
  48. package/core/hooks/guards.ts +155 -0
  49. package/core/hooks/registry.ts +247 -0
  50. package/core/metadata/definitions/Component.ts +1 -1
  51. package/core/metadata/index.ts +15 -4
  52. package/core/middleware/AccessLog.ts +8 -1
  53. package/core/middleware/RateLimit.ts +102 -105
  54. package/core/middleware/RequestId.ts +2 -9
  55. package/core/middleware/SecurityHeaders.ts +2 -11
  56. package/core/middleware/headers.ts +28 -0
  57. package/core/remote/OutboxWorker.ts +213 -183
  58. package/core/remote/RemoteManager.ts +401 -400
  59. package/core/remote/types.ts +153 -151
  60. package/core/requestScope.ts +34 -0
  61. package/core/scheduler/cronEvaluator.ts +174 -0
  62. package/core/scheduler/lifecycleHooks.ts +21 -0
  63. package/core/scheduler/lockCoordinator.ts +27 -0
  64. package/core/scheduler/metrics.ts +14 -0
  65. package/core/scheduler/taskRunner.ts +420 -0
  66. package/database/DatabaseHelper.ts +128 -101
  67. package/database/IndexingStrategy.ts +72 -2
  68. package/database/PreparedStatementCache.ts +20 -5
  69. package/database/cancellable.ts +35 -0
  70. package/database/index.ts +15 -3
  71. package/database/instrumentedDb.ts +141 -0
  72. package/endpoints/archetypes.ts +2 -8
  73. package/endpoints/tables.ts +6 -1
  74. package/gql/index.ts +1 -1
  75. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  76. package/package.json +22 -1
  77. package/query/CTENode.ts +5 -3
  78. package/query/ComponentInclusionNode.ts +240 -13
  79. package/query/OrNode.ts +6 -5
  80. package/query/Query.ts +203 -59
  81. package/query/QueryContext.ts +6 -0
  82. package/query/QueryDAG.ts +7 -2
  83. package/query/membershipSource.ts +66 -0
  84. package/storage/LocalStorageProvider.ts +8 -3
  85. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  86. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  87. package/studio/{index.html → dist/index.html} +3 -2
  88. package/swagger/generator.ts +11 -1
  89. package/upload/UploadManager.ts +8 -6
  90. package/utils/uuid.ts +40 -10
  91. package/.claude/settings.local.json +0 -47
  92. package/.prettierrc +0 -4
  93. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  94. package/.serena/memories/architecture.md +0 -154
  95. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  96. package/.serena/memories/code_style_and_conventions.md +0 -76
  97. package/.serena/memories/project_overview.md +0 -43
  98. package/.serena/memories/schema-dsl-plan.md +0 -107
  99. package/.serena/memories/suggested_commands.md +0 -80
  100. package/.serena/memories/typescript-compilation-status.md +0 -54
  101. package/.serena/project.yml +0 -114
  102. package/BunSane.jpg +0 -0
  103. package/CLAUDE.md +0 -198
  104. package/TODO.md +0 -2
  105. package/bun.lock +0 -302
  106. package/bunfig.toml +0 -10
  107. package/docs/SCALABILITY_PLAN.md +0 -175
  108. package/studio/bun.lock +0 -482
  109. package/studio/package.json +0 -39
  110. package/studio/postcss.config.js +0 -6
  111. package/studio/src/components/DataTable.tsx +0 -211
  112. package/studio/src/components/Layout.tsx +0 -13
  113. package/studio/src/components/PageContainer.tsx +0 -9
  114. package/studio/src/components/PageHeader.tsx +0 -13
  115. package/studio/src/components/SearchBar.tsx +0 -57
  116. package/studio/src/components/Sidebar.tsx +0 -294
  117. package/studio/src/components/ui/button.tsx +0 -56
  118. package/studio/src/components/ui/checkbox.tsx +0 -26
  119. package/studio/src/components/ui/input.tsx +0 -25
  120. package/studio/src/hooks/useDataTable.ts +0 -131
  121. package/studio/src/index.css +0 -36
  122. package/studio/src/lib/api.ts +0 -186
  123. package/studio/src/lib/utils.ts +0 -13
  124. package/studio/src/main.tsx +0 -17
  125. package/studio/src/pages/ArcheType.tsx +0 -239
  126. package/studio/src/pages/Components.tsx +0 -124
  127. package/studio/src/pages/EntityInspector.tsx +0 -302
  128. package/studio/src/pages/QueryRunner.tsx +0 -246
  129. package/studio/src/pages/Table.tsx +0 -94
  130. package/studio/src/pages/Welcome.tsx +0 -241
  131. package/studio/src/routes.tsx +0 -45
  132. package/studio/src/store/archeTypeSettings.ts +0 -30
  133. package/studio/src/store/studio.ts +0 -65
  134. package/studio/src/utils/columnHelpers.tsx +0 -114
  135. package/studio/studio-instructions.md +0 -81
  136. package/studio/tailwind.config.js +0 -77
  137. package/studio/utils.ts +0 -54
  138. package/studio/vite.config.js +0 -19
  139. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  140. package/tests/benchmark/bunfig.toml +0 -9
  141. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  142. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  143. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  144. package/tests/benchmark/fixtures/index.ts +0 -6
  145. package/tests/benchmark/index.ts +0 -22
  146. package/tests/benchmark/noop-preload.ts +0 -3
  147. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  148. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  149. package/tests/benchmark/runners/index.ts +0 -4
  150. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  151. package/tests/benchmark/scripts/generate-db.ts +0 -344
  152. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  153. package/tests/e2e/http.test.ts +0 -130
  154. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  155. package/tests/fixtures/components/TestOrder.ts +0 -23
  156. package/tests/fixtures/components/TestProduct.ts +0 -23
  157. package/tests/fixtures/components/TestUser.ts +0 -20
  158. package/tests/fixtures/components/index.ts +0 -6
  159. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  160. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  161. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  162. package/tests/helpers/MockRedisClient.ts +0 -113
  163. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  164. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  165. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  166. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  167. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  168. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  169. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  170. package/tests/integration/query/Query.exec.test.ts +0 -576
  171. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  172. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  173. package/tests/integration/remote/dlq.test.ts +0 -175
  174. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  175. package/tests/integration/remote/outbox.test.ts +0 -130
  176. package/tests/integration/remote/rpc.test.ts +0 -177
  177. package/tests/pglite-setup.ts +0 -62
  178. package/tests/setup.ts +0 -164
  179. package/tests/stress/BenchmarkRunner.ts +0 -203
  180. package/tests/stress/DataSeeder.ts +0 -190
  181. package/tests/stress/StressTestReporter.ts +0 -229
  182. package/tests/stress/cursor-perf-test.ts +0 -171
  183. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  184. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  185. package/tests/stress/index.ts +0 -7
  186. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  187. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  188. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  189. package/tests/unit/BatchLoader.test.ts +0 -196
  190. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  191. package/tests/unit/cache/CacheManager.test.ts +0 -367
  192. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  193. package/tests/unit/cache/RedisCache.test.ts +0 -411
  194. package/tests/unit/entity/Entity.components.test.ts +0 -317
  195. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  196. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  197. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  198. package/tests/unit/entity/Entity.test.ts +0 -345
  199. package/tests/unit/gql/depthLimit.test.ts +0 -203
  200. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  201. package/tests/unit/health/Health.test.ts +0 -129
  202. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  203. package/tests/unit/middleware/Middleware.test.ts +0 -98
  204. package/tests/unit/middleware/RequestId.test.ts +0 -54
  205. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  206. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  207. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  208. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  209. package/tests/unit/query/Query.test.ts +0 -310
  210. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  211. package/tests/unit/remote/RemoteError.test.ts +0 -55
  212. package/tests/unit/remote/decorators.test.ts +0 -195
  213. package/tests/unit/remote/metrics.test.ts +0 -115
  214. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  215. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  216. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  217. package/tests/unit/schema/schema-integration.test.ts +0 -426
  218. package/tests/unit/schema/schema.test.ts +0 -580
  219. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  220. package/tests/unit/upload/RestUpload.test.ts +0 -267
  221. package/tests/unit/validateEnv.test.ts +0 -82
  222. package/tests/utils/entity-tracker.ts +0 -57
  223. package/tests/utils/index.ts +0 -13
  224. package/tests/utils/test-context.ts +0 -149
@@ -0,0 +1,364 @@
1
+ // Component access + mutation for Entity (add/set/remove/get/has/reload
2
+ // and the in-memory helpers). Extracted from Entity.ts
3
+ // (RFC_REFACTOR_TARGETS §3.2). Pure functions take the entity instance as
4
+ // the first parameter; hook phases/order are byte-identical to the
5
+ // original inline implementation.
6
+ import type { ComponentDataType, ComponentGetter, BaseComponent } from "../components";
7
+ import { logger } from "../Logger";
8
+ import db from "../../database";
9
+ import { runWithSignal } from "../../database/cancellable";
10
+ import ComponentRegistry from "../components/ComponentRegistry";
11
+ import { SQL } from "bun";
12
+ import EntityHookManager from "../EntityHookManager";
13
+ import { getMetadataStorage } from "../metadata";
14
+ import { ComponentAddedEvent, ComponentUpdatedEvent, ComponentRemovedEvent } from "../events/EntityLifecycleEvents";
15
+ import { getRequestScope } from "../requestScope";
16
+ import { trackCacheOp } from "./pendingOps";
17
+ import type { Entity } from "../Entity";
18
+
19
+ export function addComponent(entity: Entity, component: BaseComponent): Entity {
20
+ entity.components.set(component.getTypeID(), component);
21
+ return entity;
22
+ }
23
+
24
+ /**
25
+ * Resolve a component constructor to its type id. `getComponentId` is
26
+ * memoized in metadata storage, so this is an O(1) Map lookup with no
27
+ * component instantiation — unlike `new ctor().getTypeID()`. The
28
+ * `components` map is keyed by type id (see addComponent), so callers can
29
+ * then do `entity.components.get(typeId)` instead of allocating an array and
30
+ * scanning it with `instanceof`.
31
+ */
32
+ export function typeIdOf(ctor: new (...args: any[]) => BaseComponent): string {
33
+ return getMetadataStorage().getComponentId(ctor.name);
34
+ }
35
+
36
+ export function componentList(entity: Entity): BaseComponent[] {
37
+ return Array.from(entity.components.values());
38
+ }
39
+
40
+ export function getInMemory<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): T | undefined {
41
+ return entity.components.get(typeIdOf(ctor)) as T | undefined;
42
+ }
43
+
44
+ export function hasInMemory<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): boolean {
45
+ return entity.components.has(typeIdOf(ctor));
46
+ }
47
+
48
+ export function wasRemoved<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): boolean {
49
+ const typeId = typeIdOf(ctor);
50
+ // Check both pending removals and already-saved removals
51
+ return entity.removedComponents.has(typeId) || entity.savedRemovedComponents.has(typeId);
52
+ }
53
+
54
+ export function add<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, data?: Partial<ComponentDataType<T>>): Entity {
55
+ const instance = new ctor();
56
+ if (data) {
57
+ Object.assign(instance, data);
58
+ } else {
59
+ Object.assign(instance, {});
60
+ }
61
+ addComponent(entity, instance);
62
+ entity.setDirty(true);
63
+ // executeHooks is async; the surrounding try/catch only captures
64
+ // synchronous throws. Attach a .catch so an async rejection from a
65
+ // hook handler does not escape as an unhandled rejection (H-HOOK-1).
66
+ // Add stays sync to preserve the fluent chaining signature; hook
67
+ // failures are logged and do not fail the add operation.
68
+ Promise.resolve()
69
+ .then(() => EntityHookManager.executeHooks(new ComponentAddedEvent(entity, instance)))
70
+ .catch((error) => {
71
+ logger.error(`Error firing component added hook for ${instance.getTypeID()}: ${error}`);
72
+ });
73
+
74
+ return entity;
75
+ }
76
+
77
+ export async function set<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, data: Partial<ComponentDataType<T>>, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): Promise<Entity> {
78
+ await get(entity, ctor, context);
79
+
80
+ const component = entity.components.get(typeIdOf(ctor)) as T;
81
+ if (component) {
82
+ // Store old data for the update event
83
+ const oldData = { ...component };
84
+
85
+ // Update existing component
86
+ Object.assign(component, data);
87
+ component.setDirty(true);
88
+ entity.setDirty(true);
89
+
90
+ // Fire component updated event. Await so a hook rejection is
91
+ // captured by this method's try/catch and does not escape as an
92
+ // unhandled rejection (H-HOOK-1).
93
+ try {
94
+ await EntityHookManager.executeHooks(new ComponentUpdatedEvent(entity, component, oldData, component));
95
+ } catch (error) {
96
+ logger.error(`Error firing component updated hook for ${component.getTypeID()}: ${error}`);
97
+ // Don't fail the set operation if hooks fail
98
+ }
99
+
100
+ // Invalidate DataLoader cache if context is provided
101
+ if (context?.loaders?.componentsByEntityType) {
102
+ context.loaders.componentsByEntityType.clear({
103
+ entityId: entity.id,
104
+ typeId: component.getTypeID()
105
+ });
106
+ }
107
+
108
+ // Fire-and-forget cache update, tracked via drainable set so
109
+ // App.shutdown can await it (H-CACHE-1).
110
+ trackCacheOp((async () => {
111
+ try {
112
+ const { CacheManager } = await import('../cache/CacheManager');
113
+ const cacheManager = CacheManager.getInstance();
114
+ const config = cacheManager.getConfig();
115
+
116
+ if (config.enabled && config.component?.enabled) {
117
+ if (config.strategy === 'write-through') {
118
+ await cacheManager.setComponentWriteThrough(entity.id, [component], component.getTypeID(), config.component.ttl);
119
+ } else {
120
+ await cacheManager.invalidateComponent(entity.id, component.getTypeID());
121
+ }
122
+ }
123
+ } catch (error) {
124
+ logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache operation failed after set', err: error });
125
+ }
126
+ })());
127
+ } else {
128
+ // Add new component
129
+ add(entity, ctor, data);
130
+ entity.setDirty(true);
131
+ // Note: add() already fires ComponentAddedEvent, so we don't need to fire it again
132
+ }
133
+ return entity;
134
+ }
135
+
136
+ export function remove<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): boolean {
137
+ const component = entity.components.get(typeIdOf(ctor)) as T;
138
+
139
+ if (component) {
140
+ const typeId = component.getTypeID();
141
+
142
+ // Track the component type for database deletion
143
+ entity.removedComponents.add(typeId);
144
+
145
+ // Remove the component from the map
146
+ entity.components.delete(typeId);
147
+ entity.setDirty(true);
148
+
149
+ // Fire component removed event. remove() stays sync to preserve
150
+ // the boolean return signature used by callers; attach .catch so
151
+ // async hook rejections do not escape (H-HOOK-1).
152
+ Promise.resolve()
153
+ .then(() => EntityHookManager.executeHooks(new ComponentRemovedEvent(entity, component)))
154
+ .catch((error) => {
155
+ logger.error(`Error firing component removed hook for ${typeId}: ${error}`);
156
+ });
157
+
158
+ // Invalidate DataLoader cache if context is provided
159
+ if (context?.loaders?.componentsByEntityType) {
160
+ context.loaders.componentsByEntityType.clear({
161
+ entityId: entity.id,
162
+ typeId: typeId
163
+ });
164
+ }
165
+
166
+ // Fire-and-forget cache invalidation, tracked for shutdown drain
167
+ // (H-CACHE-1).
168
+ trackCacheOp((async () => {
169
+ try {
170
+ const { CacheManager } = await import('../cache/CacheManager');
171
+ const cacheManager = CacheManager.getInstance();
172
+ const config = cacheManager.getConfig();
173
+
174
+ if (config.enabled && config.component?.enabled) {
175
+ await cacheManager.invalidateComponent(entity.id, typeId);
176
+ }
177
+ } catch (error) {
178
+ logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache invalidation failed after remove', err: error });
179
+ }
180
+ })());
181
+
182
+ return true;
183
+ }
184
+
185
+ return false;
186
+ }
187
+
188
+ export async function get<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): Promise<ComponentDataType<T> | null> {
189
+ const comp = await loadComponent(entity, ctor, context);
190
+ return comp ? (comp as ComponentGetter<T>).data() : null;
191
+ }
192
+
193
+ export function has<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): boolean {
194
+ return hasInMemory(entity, ctor);
195
+ }
196
+
197
+ export async function getOrThrow<T extends BaseComponent>(
198
+ entity: Entity,
199
+ ctor: new (...args: any[]) => T,
200
+ context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }
201
+ ): Promise<ComponentDataType<T>> {
202
+ const data = await get(entity, ctor, context);
203
+ if (data === null) {
204
+ throw new Error(`Entity ${entity.id} is missing required component ${ctor.name}`);
205
+ }
206
+ return data;
207
+ }
208
+
209
+ export function getCached<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): ComponentDataType<T> | undefined {
210
+ const comp = getInMemory(entity, ctor);
211
+ return comp ? (comp as ComponentGetter<T>).data() : undefined;
212
+ }
213
+
214
+ export async function getInstanceOf<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): Promise<T | null> {
215
+ return loadComponent(entity, ctor, context);
216
+ }
217
+
218
+ export async function reload(entity: Entity, opts?: { trx?: SQL; signal?: AbortSignal }): Promise<Entity> {
219
+ if (!entity.id || entity.id.trim() === '') {
220
+ return entity;
221
+ }
222
+ entity.components.clear();
223
+ entity.removedComponents.clear();
224
+ entity.savedRemovedComponents.clear();
225
+
226
+ const dbConn = opts?.trx ?? db;
227
+ const rows = await runWithSignal<any[]>(
228
+ dbConn`
229
+ SELECT c.id, c.type_id, c.data
230
+ FROM components c
231
+ WHERE c.entity_id = ${entity.id} AND c.deleted_at IS NULL
232
+ `,
233
+ opts?.signal
234
+ );
235
+
236
+ const storage = getMetadataStorage();
237
+ for (const row of rows) {
238
+ const ctor = ComponentRegistry.getConstructor(row.type_id);
239
+ if (!ctor) continue;
240
+ const comp: any = new ctor();
241
+ const parsed = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
242
+ Object.assign(comp, parsed);
243
+ comp.id = row.id;
244
+ const props = storage.componentProperties.get(row.type_id);
245
+ if (props) {
246
+ for (const prop of props) {
247
+ if (prop.propertyType === Date && typeof comp[prop.propertyKey] === 'string') {
248
+ comp[prop.propertyKey] = new Date(comp[prop.propertyKey]);
249
+ }
250
+ }
251
+ }
252
+ comp.setPersisted(true);
253
+ comp.setDirty(false);
254
+ addComponent(entity, comp);
255
+ }
256
+
257
+ entity.setPersisted(true);
258
+ entity.setDirty(false);
259
+ return entity;
260
+ }
261
+
262
+ export async function requireComponents(entity: Entity, ctors: Array<new (...args: any[]) => BaseComponent>): Promise<void> {
263
+ if (ctors.length === 0) return;
264
+ const missing: string[] = [];
265
+ for (const ctor of ctors) {
266
+ // components is keyed by type id — O(1) lookup, no instantiation
267
+ // and no O(K) instanceof scan per constructor.
268
+ const typeId = typeIdOf(ctor);
269
+ if (!entity.components.has(typeId)) {
270
+ missing.push(typeId);
271
+ }
272
+ }
273
+ if (missing.length === 0) return;
274
+ const { Entity } = await import("../Entity");
275
+ await Entity.LoadComponents([entity], missing);
276
+ }
277
+
278
+ async function loadComponent<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): Promise<T | null> {
279
+ const comp = entity.components.get(typeIdOf(ctor)) as T | undefined;
280
+ if (typeof comp !== "undefined") {
281
+ return comp;
282
+ }
283
+
284
+ // Validate entity ID before database query
285
+ if (!entity.id || entity.id.trim() === '') {
286
+ logger.warn(`Cannot load component ${ctor.name}: entity id is empty`);
287
+ return null;
288
+ }
289
+
290
+ // Memoized metadata lookup — no throwaway component instantiation
291
+ // just to read the type id.
292
+ const typeId = typeIdOf(ctor);
293
+
294
+ // Use transaction if provided, otherwise use default db
295
+ const dbConn = context?.trx ?? db;
296
+
297
+ // Ambient request scope fallback: bare entity.get() calls (e.g.
298
+ // inside @ArcheTypeFunction bodies or Unwrap()) batch through the
299
+ // request's DataLoaders instead of firing one SELECT per call.
300
+ // Never substituted when the caller passed an explicit trx — a
301
+ // loader read outside the transaction could see stale data.
302
+ const scope = (!context?.loaders && !context?.trx) ? getRequestScope() : undefined;
303
+ const loaders = context?.loaders ?? scope?.loaders;
304
+ const signal = context?.signal ?? scope?.signal;
305
+
306
+ try {
307
+ let componentData: any = null;
308
+ let componentId: string | null = null;
309
+
310
+ if (loaders?.componentsByEntityType) {
311
+ const loaderResult = await loaders.componentsByEntityType.load({
312
+ entityId: entity.id,
313
+ typeId: typeId
314
+ });
315
+ if (loaderResult) {
316
+ componentData = loaderResult.data;
317
+ componentId = loaderResult.id;
318
+ }
319
+ } else {
320
+ // Route through runWithSignal so a request/wall-clock abort can
321
+ // cancel this in-flight read. When dbConn is context.trx, an
322
+ // uncancelled read leaks the backend into `idle in transaction`
323
+ // on timeout (matches the d1dde84 save/delete fix, which missed
324
+ // the read path).
325
+ const rows = await runWithSignal<any[]>(
326
+ dbConn`SELECT id, data FROM components WHERE entity_id = ${entity.id} AND type_id = ${typeId} AND deleted_at IS NULL`,
327
+ signal
328
+ );
329
+ if (rows.length > 0) {
330
+ componentData = rows[0].data;
331
+ componentId = rows[0].id;
332
+ }
333
+ }
334
+
335
+ if (componentData !== null) {
336
+ const comp: any = new ctor();
337
+ if (componentId) {
338
+ comp.id = componentId;
339
+ }
340
+ const parsedData = typeof componentData === 'string' ? JSON.parse(componentData) : componentData;
341
+ Object.assign(comp, parsedData);
342
+ const storage = getMetadataStorage();
343
+ const props = storage.componentProperties.get(typeId);
344
+ if (props) {
345
+ for (const prop of props) {
346
+ if (prop.propertyType === Date && typeof comp[prop.propertyKey] === 'string') {
347
+ comp[prop.propertyKey] = new Date(comp[prop.propertyKey]);
348
+ }
349
+ }
350
+ }
351
+ comp.setPersisted(true);
352
+ comp.setDirty(false);
353
+ addComponent(entity, comp);
354
+ return comp as T;
355
+ } else {
356
+ return null;
357
+ }
358
+ } catch (error) {
359
+ logger.error(`Failed to fetch component ${ctor.name}: ${error}`);
360
+ return null;
361
+ }
362
+ }
363
+
364
+ export { loadComponent };
@@ -0,0 +1,202 @@
1
+ // Entity loaders, clone/ref factories, and (de)serialization. Extracted
2
+ // from Entity.ts (RFC_REFACTOR_TARGETS §3.2). Functions take/return the
3
+ // Entity instance; the Entity class is imported lazily where construction
4
+ // is needed to avoid a module-eval cycle.
5
+ import { logger } from "../Logger";
6
+ import db from "../../database";
7
+ import ComponentRegistry from "../components/ComponentRegistry";
8
+ import { uuidv7 } from "../../utils/uuid";
9
+ import { sql, SQL } from "bun";
10
+ import { getMetadataStorage } from "../metadata";
11
+ import { addComponent } from "./componentAccess";
12
+ // Value import: the Entity class is only referenced inside function bodies
13
+ // (called at runtime, after module init), so the ESM cycle with Entity.ts
14
+ // resolves via live bindings without a load-order hazard.
15
+ import { Entity } from "../Entity";
16
+
17
+ export async function loadMultiple(ids: string[]): Promise<Entity[]> {
18
+ if (ids.length === 0) return [];
19
+
20
+ // Filter out empty/invalid IDs to prevent PostgreSQL UUID parsing errors
21
+ const validIds = ids.filter(id => id && id.trim() !== '');
22
+ if (validIds.length === 0) return [];
23
+ if (validIds.length !== ids.length) {
24
+ logger.warn(`LoadMultiple: Filtered out ${ids.length - validIds.length} invalid entity IDs`);
25
+ }
26
+
27
+ const components = await db`
28
+ SELECT c.id, c.entity_id, c.type_id, c.data
29
+ FROM components c
30
+ WHERE c.entity_id IN ${sql(validIds)} AND c.deleted_at IS NULL
31
+ `;
32
+
33
+ const entitiesMap = new Map<string, Entity>();
34
+
35
+ for (const id of validIds) {
36
+ const entity = new Entity();
37
+ entity.id = id;
38
+ entity.setPersisted(true);
39
+ entity.setDirty(false);
40
+ entitiesMap.set(id, entity);
41
+ }
42
+
43
+ for (const row of components) {
44
+ const { id, entity_id, type_id, data } = row;
45
+ const ctor = ComponentRegistry.getConstructor(type_id);
46
+ if (ctor) {
47
+ const comp = new ctor();
48
+ const componentData = typeof data === 'string' ? JSON.parse(data) : data;
49
+ Object.assign(comp, componentData);
50
+ comp.id = id;
51
+ comp.setPersisted(true);
52
+ comp.setDirty(false);
53
+ const target = entitiesMap.get(entity_id);
54
+ if (target) addComponent(target, comp);
55
+ }
56
+ }
57
+
58
+ return Array.from(entitiesMap.values());
59
+ }
60
+
61
+ export async function loadComponents(entities: Entity[], componentIds: string[], skipCache: boolean = false): Promise<void> {
62
+ if (entities.length === 0 || componentIds.length === 0) return;
63
+
64
+ // Filter out entities with empty/invalid IDs to prevent PostgreSQL UUID parsing errors
65
+ const validEntities = entities.filter(e => e.id && e.id.trim() !== '');
66
+ if (validEntities.length === 0) return;
67
+
68
+ const entityIds = validEntities.map(e => e.id);
69
+
70
+ const components = await db`
71
+ SELECT c.id, c.entity_id, c.type_id, c.data
72
+ FROM components c
73
+ WHERE c.entity_id IN ${sql(entityIds)} AND c.type_id IN ${sql(componentIds)} AND c.deleted_at IS NULL
74
+ `;
75
+
76
+ // Use Map for O(1) lookups instead of O(n) find() - fixes O(n²) performance issue
77
+ const entityMap = new Map<string, Entity>(validEntities.map(e => [e.id, e]));
78
+
79
+ for (const row of components) {
80
+ const { id, entity_id, type_id, data } = row;
81
+ const entity = entityMap.get(entity_id); // O(1) instead of O(n)
82
+ if (entity) {
83
+ const ctor = ComponentRegistry.getConstructor(type_id);
84
+ if (ctor) {
85
+ const comp = new ctor();
86
+ const componentData = typeof data === 'string' ? JSON.parse(data) : data;
87
+ Object.assign(comp, componentData);
88
+ comp.id = id;
89
+ comp.setPersisted(true);
90
+ comp.setDirty(false);
91
+ addComponent(entity, comp);
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Find an entity by its ID. Returning populated with all components. Or null if not found.
99
+ */
100
+ export async function findById(id: string, trx?: SQL): Promise<Entity | null> {
101
+ // Validate ID to prevent PostgreSQL UUID parsing errors
102
+ if (!id || typeof id !== 'string' || id.trim() === '') {
103
+ logger.warn(`FindById called with invalid id: "${id}"`);
104
+ return null;
105
+ }
106
+ const { Query } = await import("../../query/Query");
107
+ const entities = await new Query(trx).findById(id).populate().exec()
108
+ if (entities.length === 1) {
109
+ return entities[0]!;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ export function clone(entity: Entity): Entity {
115
+ const clone = new Entity();
116
+ clone.setDirty(true);
117
+ clone.setPersisted(false);
118
+ for (const comp of entity.components.values()) {
119
+ const newComp = new (comp.constructor as any)();
120
+ Object.assign(newComp, comp.data());
121
+ newComp.id = uuidv7();
122
+ newComp.setDirty(true);
123
+ newComp.setPersisted(false);
124
+ addComponent(clone, newComp);
125
+ }
126
+ return clone;
127
+ }
128
+
129
+ export function makeRef(entity: Entity): Entity {
130
+ const ref = new Entity();
131
+ ref.setDirty(true);
132
+ ref.setPersisted(false);
133
+ for (const comp of entity.components.values()) {
134
+ const refComp = comp;
135
+ refComp.setDirty(false);
136
+ refComp.setPersisted(true);
137
+ addComponent(ref, refComp);
138
+ }
139
+ return ref;
140
+ }
141
+
142
+ /**
143
+ * Serialize the entity with only the currently loaded components
144
+ */
145
+ export function serialize(entity: Entity): { id: string; components: Record<string, any> } {
146
+ const components: Record<string, any> = {};
147
+ for (const comp of entity.components.values()) {
148
+ components[comp.constructor.name] = comp.serializableData();
149
+ }
150
+ return {
151
+ id: entity.id,
152
+ components
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Deserialize/reconstitute an Entity from cached/serialized data.
158
+ */
159
+ export function deserialize(data: any): Entity {
160
+ if (data instanceof Entity) {
161
+ return data;
162
+ }
163
+
164
+ const entity = new Entity(data.id);
165
+ entity.setPersisted(true);
166
+ entity.setDirty(false);
167
+
168
+ // Handle serialized format: { id, components: { ComponentName: {...data} } }
169
+ if (data.components && typeof data.components === 'object') {
170
+ const storage = getMetadataStorage();
171
+
172
+ for (const [componentName, componentData] of Object.entries(data.components)) {
173
+ // Find the component constructor by name
174
+ const ComponentCtor = ComponentRegistry.getConstructorByName(componentName);
175
+ if (!ComponentCtor) {
176
+ logger.warn(`Cannot deserialize component: constructor not found for ${componentName}`);
177
+ continue;
178
+ }
179
+
180
+ const comp = new ComponentCtor();
181
+ const parsedData = typeof componentData === 'string' ? JSON.parse(componentData) : componentData;
182
+ Object.assign(comp, parsedData);
183
+
184
+ // Restore Date objects
185
+ const typeId = comp.getTypeID();
186
+ const props = storage.componentProperties.get(typeId);
187
+ if (props) {
188
+ for (const prop of props) {
189
+ if (prop.propertyType === Date && typeof (comp as any)[prop.propertyKey] === 'string') {
190
+ (comp as any)[prop.propertyKey] = new Date((comp as any)[prop.propertyKey]);
191
+ }
192
+ }
193
+ }
194
+
195
+ comp.setPersisted(true);
196
+ comp.setDirty(false);
197
+ addComponent(entity, comp);
198
+ }
199
+ }
200
+
201
+ return entity;
202
+ }
@@ -0,0 +1,72 @@
1
+ // Drainable background-work tracking for Entity. Extracted from Entity.ts
2
+ // (RFC_REFACTOR_TARGETS §3.2). Module-level state owns the Sets; Entity
3
+ // keeps thin public static delegates for external callers.
4
+
5
+ // Drainable set of fire-and-forget cache ops triggered from set/remove.
6
+ // App.shutdown can await these to avoid losing writes mid-shutdown
7
+ // (H-CACHE-1).
8
+ const pendingCacheOps: Set<Promise<void>> = new Set();
9
+
10
+ // Drainable set of post-commit side-effect Promises scheduled via
11
+ // queueMicrotask from save(). Includes cache invalidation + lifecycle
12
+ // hooks (EntityCreated / EntityUpdated). Hooks may transitively trigger
13
+ // more DB work (e.g., entity.save() from a handler), which is why this
14
+ // is tracked separately from pendingCacheOps. Tests running against
15
+ // PGlite's single-connection pool should drain this between test files
16
+ // to prevent background work from prior files queueing behind the
17
+ // current file's save and masking visibility of recently-committed
18
+ // rows. See BUNSANE-001.
19
+ const pendingSideEffects: Set<Promise<void>> = new Set();
20
+
21
+ /**
22
+ * Await all pending background cache operations. Call during shutdown
23
+ * after HTTP drain but before cache.disconnect so setImmediate'd cache
24
+ * writes are not lost. Bounded by `timeoutMs`.
25
+ */
26
+ export async function drainPendingCacheOps(timeoutMs: number = 5_000): Promise<void> {
27
+ if (pendingCacheOps.size === 0) return;
28
+ const snapshot = [...pendingCacheOps];
29
+ const drainTimer = new Promise<'timeout'>((resolve) => {
30
+ const t = setTimeout(() => resolve('timeout'), timeoutMs);
31
+ t.unref?.();
32
+ });
33
+ await Promise.race([
34
+ Promise.allSettled(snapshot).then(() => 'drained' as const),
35
+ drainTimer,
36
+ ]);
37
+ }
38
+
39
+ /**
40
+ * Await all pending post-commit side effects (cache invalidation +
41
+ * lifecycle hooks scheduled via queueMicrotask from save()). Call from
42
+ * test setup/teardown hooks under PGlite to guarantee prior-file
43
+ * background work has settled before the next file's saves run. Bounded
44
+ * by `timeoutMs`. Safe to call repeatedly; no-op when the set is empty.
45
+ */
46
+ export async function drainPendingSideEffects(timeoutMs: number = 5_000): Promise<void> {
47
+ if (pendingSideEffects.size === 0) return;
48
+ const snapshot = [...pendingSideEffects];
49
+ const drainTimer = new Promise<'timeout'>((resolve) => {
50
+ const t = setTimeout(() => resolve('timeout'), timeoutMs);
51
+ t.unref?.();
52
+ });
53
+ await Promise.race([
54
+ Promise.allSettled(snapshot).then(() => 'drained' as const),
55
+ drainTimer,
56
+ ]);
57
+ }
58
+
59
+ /**
60
+ * Track a fire-and-forget cache promise in the drainable set. Public so
61
+ * other framework read paths (e.g. Query.populateComponents cache
62
+ * warming) share the same drain semantics (H-CACHE-1).
63
+ */
64
+ export function trackCacheOp(p: Promise<void>): void {
65
+ pendingCacheOps.add(p);
66
+ p.finally(() => pendingCacheOps.delete(p));
67
+ }
68
+
69
+ export function trackSideEffect(p: Promise<void>): void {
70
+ pendingSideEffects.add(p);
71
+ p.finally(() => pendingSideEffects.delete(p));
72
+ }