bunsane 0.3.2 → 0.4.0

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