bunsane 0.3.2 → 0.5.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 (220) hide show
  1. package/CHANGELOG.md +471 -370
  2. package/core/BatchLoader.ts +56 -32
  3. package/core/Entity.ts +93 -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 +8 -7
  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 +25 -10
  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 +383 -0
  28. package/core/entity/finders.ts +202 -0
  29. package/core/entity/getCacheManager.ts +10 -0
  30. package/core/entity/pendingOps.ts +72 -0
  31. package/core/entity/saveEntity.ts +375 -0
  32. package/core/health.ts +93 -4
  33. package/core/hooks/dispatcher.ts +439 -0
  34. package/core/hooks/guards.ts +155 -0
  35. package/core/hooks/registry.ts +247 -0
  36. package/core/metadata/definitions/Component.ts +1 -1
  37. package/core/metadata/index.ts +15 -4
  38. package/core/middleware/RateLimit.ts +102 -105
  39. package/core/middleware/RequestId.ts +2 -9
  40. package/core/middleware/SecurityHeaders.ts +2 -11
  41. package/core/middleware/headers.ts +28 -0
  42. package/core/remote/OutboxWorker.ts +213 -183
  43. package/core/remote/RemoteManager.ts +401 -400
  44. package/core/remote/StreamConsumer.ts +535 -535
  45. package/core/remote/types.ts +153 -151
  46. package/core/requestScope.ts +34 -0
  47. package/core/scheduler/cronEvaluator.ts +174 -0
  48. package/core/scheduler/lifecycleHooks.ts +21 -0
  49. package/core/scheduler/lockCoordinator.ts +27 -0
  50. package/core/scheduler/metrics.ts +14 -0
  51. package/core/scheduler/taskRunner.ts +420 -0
  52. package/core/validateEnv.ts +10 -0
  53. package/database/DatabaseHelper.ts +128 -101
  54. package/database/IndexingStrategy.ts +72 -2
  55. package/database/PreparedStatementCache.ts +8 -2
  56. package/database/cancellable.ts +35 -22
  57. package/database/index.ts +29 -3
  58. package/database/instrumentedDb.ts +141 -141
  59. package/database/sqlHelpers.ts +3 -1
  60. package/endpoints/archetypes.ts +2 -8
  61. package/endpoints/tables.ts +6 -1
  62. package/gql/index.ts +1 -1
  63. package/gql/schema/index.ts +15 -4
  64. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  65. package/package.json +22 -1
  66. package/query/CTENode.ts +5 -3
  67. package/query/ComponentInclusionNode.ts +245 -14
  68. package/query/OrNode.ts +8 -19
  69. package/query/Query.ts +208 -79
  70. package/query/QueryContext.ts +6 -0
  71. package/query/QueryDAG.ts +7 -2
  72. package/query/membershipSource.ts +66 -0
  73. package/storage/LocalStorageProvider.ts +8 -3
  74. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  75. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  76. package/studio/{index.html → dist/index.html} +3 -2
  77. package/swagger/generator.ts +11 -1
  78. package/upload/UploadManager.ts +8 -6
  79. package/utils/uuid.ts +40 -10
  80. package/.claude/scheduled_tasks.lock +0 -1
  81. package/.claude/settings.local.json +0 -47
  82. package/.prettierrc +0 -4
  83. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  84. package/.serena/memories/architecture.md +0 -154
  85. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  86. package/.serena/memories/code_style_and_conventions.md +0 -76
  87. package/.serena/memories/project_overview.md +0 -43
  88. package/.serena/memories/schema-dsl-plan.md +0 -107
  89. package/.serena/memories/suggested_commands.md +0 -80
  90. package/.serena/memories/typescript-compilation-status.md +0 -54
  91. package/.serena/project.yml +0 -114
  92. package/BunSane.jpg +0 -0
  93. package/CLAUDE.md +0 -198
  94. package/TODO.md +0 -2
  95. package/bun.lock +0 -302
  96. package/bunfig.toml +0 -10
  97. package/docs/RFC_APP_REFACTOR.md +0 -248
  98. package/docs/RFC_REFACTOR_TARGETS.md +0 -251
  99. package/docs/SCALABILITY_PLAN.md +0 -175
  100. package/studio/bun.lock +0 -482
  101. package/studio/package.json +0 -39
  102. package/studio/postcss.config.js +0 -6
  103. package/studio/src/components/DataTable.tsx +0 -211
  104. package/studio/src/components/Layout.tsx +0 -13
  105. package/studio/src/components/PageContainer.tsx +0 -9
  106. package/studio/src/components/PageHeader.tsx +0 -13
  107. package/studio/src/components/SearchBar.tsx +0 -57
  108. package/studio/src/components/Sidebar.tsx +0 -294
  109. package/studio/src/components/ui/button.tsx +0 -56
  110. package/studio/src/components/ui/checkbox.tsx +0 -26
  111. package/studio/src/components/ui/input.tsx +0 -25
  112. package/studio/src/hooks/useDataTable.ts +0 -131
  113. package/studio/src/index.css +0 -36
  114. package/studio/src/lib/api.ts +0 -186
  115. package/studio/src/lib/utils.ts +0 -13
  116. package/studio/src/main.tsx +0 -17
  117. package/studio/src/pages/ArcheType.tsx +0 -239
  118. package/studio/src/pages/Components.tsx +0 -124
  119. package/studio/src/pages/EntityInspector.tsx +0 -302
  120. package/studio/src/pages/QueryRunner.tsx +0 -246
  121. package/studio/src/pages/Table.tsx +0 -94
  122. package/studio/src/pages/Welcome.tsx +0 -241
  123. package/studio/src/routes.tsx +0 -45
  124. package/studio/src/store/archeTypeSettings.ts +0 -30
  125. package/studio/src/store/studio.ts +0 -65
  126. package/studio/src/utils/columnHelpers.tsx +0 -114
  127. package/studio/studio-instructions.md +0 -81
  128. package/studio/tailwind.config.js +0 -77
  129. package/studio/utils.ts +0 -54
  130. package/studio/vite.config.js +0 -19
  131. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  132. package/tests/benchmark/bunfig.toml +0 -9
  133. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  134. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  135. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  136. package/tests/benchmark/fixtures/index.ts +0 -6
  137. package/tests/benchmark/index.ts +0 -22
  138. package/tests/benchmark/noop-preload.ts +0 -3
  139. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  140. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  141. package/tests/benchmark/runners/index.ts +0 -4
  142. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  143. package/tests/benchmark/scripts/generate-db.ts +0 -344
  144. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  145. package/tests/e2e/http.test.ts +0 -130
  146. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  147. package/tests/fixtures/components/TestOrder.ts +0 -23
  148. package/tests/fixtures/components/TestProduct.ts +0 -23
  149. package/tests/fixtures/components/TestUser.ts +0 -20
  150. package/tests/fixtures/components/index.ts +0 -6
  151. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  152. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  153. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  154. package/tests/helpers/MockRedisClient.ts +0 -113
  155. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  156. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  157. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  158. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  159. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  160. package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
  161. package/tests/integration/query/Query.abort.test.ts +0 -66
  162. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  163. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  164. package/tests/integration/query/Query.exec.test.ts +0 -576
  165. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  166. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  167. package/tests/integration/remote/dlq.test.ts +0 -175
  168. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  169. package/tests/integration/remote/outbox.test.ts +0 -130
  170. package/tests/integration/remote/rpc.test.ts +0 -177
  171. package/tests/pglite-setup.ts +0 -62
  172. package/tests/setup.ts +0 -164
  173. package/tests/stress/BenchmarkRunner.ts +0 -203
  174. package/tests/stress/DataSeeder.ts +0 -190
  175. package/tests/stress/StressTestReporter.ts +0 -229
  176. package/tests/stress/cursor-perf-test.ts +0 -171
  177. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  178. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  179. package/tests/stress/index.ts +0 -7
  180. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  181. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  182. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  183. package/tests/unit/BatchLoader.test.ts +0 -196
  184. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  185. package/tests/unit/cache/CacheManager.test.ts +0 -498
  186. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  187. package/tests/unit/cache/RedisCache.test.ts +0 -411
  188. package/tests/unit/database/cancellable.test.ts +0 -81
  189. package/tests/unit/database/instrumentedDb.test.ts +0 -160
  190. package/tests/unit/entity/Entity.components.test.ts +0 -317
  191. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  192. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  193. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  194. package/tests/unit/entity/Entity.test.ts +0 -345
  195. package/tests/unit/gql/depthLimit.test.ts +0 -203
  196. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  197. package/tests/unit/health/Health.test.ts +0 -129
  198. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  199. package/tests/unit/middleware/Middleware.test.ts +0 -98
  200. package/tests/unit/middleware/RequestId.test.ts +0 -54
  201. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  202. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  203. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  204. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  205. package/tests/unit/query/Query.test.ts +0 -310
  206. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  207. package/tests/unit/remote/RemoteError.test.ts +0 -55
  208. package/tests/unit/remote/decorators.test.ts +0 -195
  209. package/tests/unit/remote/metrics.test.ts +0 -115
  210. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  211. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  212. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  213. package/tests/unit/schema/schema-integration.test.ts +0 -426
  214. package/tests/unit/schema/schema.test.ts +0 -580
  215. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  216. package/tests/unit/upload/RestUpload.test.ts +0 -267
  217. package/tests/unit/validateEnv.test.ts +0 -82
  218. package/tests/utils/entity-tracker.ts +0 -57
  219. package/tests/utils/index.ts +0 -13
  220. package/tests/utils/test-context.ts +0 -149
@@ -7,6 +7,10 @@ import { uuidv7 } from '../../utils/uuid';
7
7
  import { getMetadataStorage } from '../metadata';
8
8
  const logger = MainLogger.child({ scope: "Components" });
9
9
 
10
+ // Cached property-name arrays keyed by typeId. Metadata is immutable after
11
+ // decorator registration, so allocating once per class is safe.
12
+ const _propNamesCache = new Map<string, string[]>();
13
+
10
14
  export class BaseComponent {
11
15
  public id: string = "";
12
16
  protected _comp_name: string = "";
@@ -26,10 +30,13 @@ export class BaseComponent {
26
30
  }
27
31
 
28
32
  properties(): string[] {
33
+ const cached = _propNamesCache.get(this._typeId);
34
+ if (cached) return cached;
29
35
  const storage = getMetadataStorage();
30
36
  const props = storage.componentProperties.get(this._typeId);
31
- if(!props) return [];
32
- return props.map(p => p.propertyKey);
37
+ const names = Object.freeze(props ? props.map(p => p.propertyKey) : []) as string[];
38
+ _propNamesCache.set(this._typeId, names);
39
+ return names;
33
40
  }
34
41
 
35
42
  /**
@@ -52,11 +59,16 @@ export class BaseComponent {
52
59
  const data: Record<string, any> = {};
53
60
  const storage = getMetadataStorage();
54
61
  const props = storage.componentProperties.get(this._typeId);
55
- this.properties().forEach((prop: string) => {
62
+ if (!props) return data;
63
+ // Iterate the property metadata directly — avoids the prior O(n²)
64
+ // pattern (properties().forEach + props.find per property) and the
65
+ // redundant second metadata lookup inside properties(). Hot write path:
66
+ // runs for every dirty component on every save.
67
+ for (const propMeta of props) {
68
+ const prop = propMeta.propertyKey;
56
69
  let value = (this as any)[prop];
57
- const propMeta = props?.find(p => p.propertyKey === prop);
58
70
  if (value !== null && value !== undefined) {
59
- if (propMeta?.propertyType === Date) {
71
+ if (propMeta.propertyType === Date) {
60
72
  if (!(value instanceof Date)) {
61
73
  throw new Error(`Type mismatch for property '${prop}' on component '${this._comp_name}': expected Date, got ${typeof value}`);
62
74
  }
@@ -64,17 +76,21 @@ export class BaseComponent {
64
76
  throw new Error(`Invalid Date for property '${prop}' on component '${this._comp_name}'`);
65
77
  }
66
78
  value = value.toISOString();
67
- } else if (propMeta?.propertyType === Number && typeof value === 'number' && !Number.isFinite(value)) {
79
+ } else if (propMeta.propertyType === Number && typeof value === 'number' && !Number.isFinite(value)) {
68
80
  throw new Error(`Invalid number for property '${prop}' on component '${this._comp_name}': ${value}`);
69
81
  }
70
82
  }
71
83
  data[prop] = value;
72
- });
84
+ }
73
85
  return data;
74
86
  }
75
87
 
76
88
  async save(trx: Bun.SQL, entity_id: string) {
77
- logger.trace(`Saving component ${this._comp_name} for entity ${entity_id}`);
89
+ // Level-gated: template literal allocates per component save even
90
+ // when trace is disabled.
91
+ if (logger.isLevelEnabled?.('trace')) {
92
+ logger.trace(`Saving component ${this._comp_name} for entity ${entity_id}`);
93
+ }
78
94
  // Only check readiness if component is not yet registered
79
95
  // This optimization avoids 40,000+ unnecessary async calls for bulk operations
80
96
  if(!ComponentRegistry.isComponentReady(this._comp_name)) {
@@ -98,10 +114,9 @@ export class BaseComponent {
98
114
  if (!entity_id || entity_id.trim() === '') {
99
115
  throw new Error(`Cannot insert component ${this._comp_name}: entity_id is empty or invalid`);
100
116
  }
101
- await trx`INSERT INTO components
117
+ await trx`INSERT INTO components
102
118
  (id, entity_id, name, type_id, data)
103
119
  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
120
  }
106
121
 
107
122
  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 { getCacheManager } from "./getCacheManager";
10
+ import type { Entity } from "../Entity";
11
+
12
+ /**
13
+ * Handle cache operations after successful save
14
+ * @param changedComponentTypeIds - Component type IDs that were dirty before save (captured before doSave clears flags)
15
+ * @param removedComponentTypeIds - Component type IDs that were removed (captured before doSave clears the set)
16
+ */
17
+ export async function handleCacheAfterSave(entity: Entity, changedComponentTypeIds: string[], removedComponentTypeIds: string[], context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): Promise<void> {
18
+ try {
19
+ const CacheManager = getCacheManager();
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 = getCacheManager();
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,383 @@
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 { getCacheManager } from "./getCacheManager";
18
+ import type { Entity } from "../Entity";
19
+
20
+ export function addComponent(entity: Entity, component: BaseComponent): Entity {
21
+ const typeId = component.getTypeID();
22
+ entity.components.set(typeId, component);
23
+ // A component that just arrived can never be "missing" — clear any
24
+ // previously recorded absence so future get() calls see the new data.
25
+ entity._missingComponents.delete(typeId);
26
+ return entity;
27
+ }
28
+
29
+ /**
30
+ * Resolve a component constructor to its type id. `getComponentId` is
31
+ * memoized in metadata storage, so this is an O(1) Map lookup with no
32
+ * component instantiation — unlike `new ctor().getTypeID()`. The
33
+ * `components` map is keyed by type id (see addComponent), so callers can
34
+ * then do `entity.components.get(typeId)` instead of allocating an array and
35
+ * scanning it with `instanceof`.
36
+ */
37
+ export function typeIdOf(ctor: new (...args: any[]) => BaseComponent): string {
38
+ return getMetadataStorage().getComponentId(ctor.name);
39
+ }
40
+
41
+ export function componentList(entity: Entity): BaseComponent[] {
42
+ return Array.from(entity.components.values());
43
+ }
44
+
45
+ export function getInMemory<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): T | undefined {
46
+ return entity.components.get(typeIdOf(ctor)) as T | undefined;
47
+ }
48
+
49
+ export function hasInMemory<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): boolean {
50
+ return entity.components.has(typeIdOf(ctor));
51
+ }
52
+
53
+ export function wasRemoved<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): boolean {
54
+ const typeId = typeIdOf(ctor);
55
+ // Check both pending removals and already-saved removals
56
+ return entity.removedComponents.has(typeId) || entity.savedRemovedComponents.has(typeId);
57
+ }
58
+
59
+ export function add<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, data?: Partial<ComponentDataType<T>>): Entity {
60
+ const instance = new ctor();
61
+ if (data) {
62
+ Object.assign(instance, data);
63
+ }
64
+ addComponent(entity, instance);
65
+ entity.setDirty(true);
66
+ // executeHooks is async; the surrounding try/catch only captures
67
+ // synchronous throws. Attach a .catch so an async rejection from a
68
+ // hook handler does not escape as an unhandled rejection (H-HOOK-1).
69
+ // Add stays sync to preserve the fluent chaining signature; hook
70
+ // failures are logged and do not fail the add operation.
71
+ Promise.resolve()
72
+ .then(() => EntityHookManager.executeHooks(new ComponentAddedEvent(entity, instance)))
73
+ .catch((error) => {
74
+ logger.error(`Error firing component added hook for ${instance.getTypeID()}: ${error}`);
75
+ });
76
+
77
+ return entity;
78
+ }
79
+
80
+ 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> {
81
+ await get(entity, ctor, context);
82
+
83
+ const component = entity.components.get(typeIdOf(ctor)) as T;
84
+ if (component) {
85
+ // Store old data for the update event
86
+ const oldData = { ...component };
87
+
88
+ // Update existing component
89
+ Object.assign(component, data);
90
+ component.setDirty(true);
91
+ entity.setDirty(true);
92
+
93
+ // Fire component updated event. Await so a hook rejection is
94
+ // captured by this method's try/catch and does not escape as an
95
+ // unhandled rejection (H-HOOK-1).
96
+ try {
97
+ await EntityHookManager.executeHooks(new ComponentUpdatedEvent(entity, component, oldData, component));
98
+ } catch (error) {
99
+ logger.error(`Error firing component updated hook for ${component.getTypeID()}: ${error}`);
100
+ // Don't fail the set operation if hooks fail
101
+ }
102
+
103
+ // Invalidate DataLoader cache if context is provided
104
+ if (context?.loaders?.componentsByEntityType) {
105
+ context.loaders.componentsByEntityType.clear({
106
+ entityId: entity.id,
107
+ typeId: component.getTypeID()
108
+ });
109
+ }
110
+
111
+ // Fire-and-forget cache update, tracked via drainable set so
112
+ // App.shutdown can await it (H-CACHE-1).
113
+ trackCacheOp((async () => {
114
+ try {
115
+ const CacheManager = getCacheManager();
116
+ const cacheManager = CacheManager.getInstance();
117
+ const config = cacheManager.getConfig();
118
+
119
+ if (config.enabled && config.component?.enabled) {
120
+ if (config.strategy === 'write-through') {
121
+ await cacheManager.setComponentWriteThrough(entity.id, [component], component.getTypeID(), config.component.ttl);
122
+ } else {
123
+ await cacheManager.invalidateComponent(entity.id, component.getTypeID());
124
+ }
125
+ }
126
+ } catch (error) {
127
+ logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache operation failed after set', err: error });
128
+ }
129
+ })());
130
+ } else {
131
+ // Add new component
132
+ add(entity, ctor, data);
133
+ entity.setDirty(true);
134
+ // Note: add() already fires ComponentAddedEvent, so we don't need to fire it again
135
+ }
136
+ return entity;
137
+ }
138
+
139
+ export function remove<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): boolean {
140
+ const component = entity.components.get(typeIdOf(ctor)) as T;
141
+
142
+ if (component) {
143
+ const typeId = component.getTypeID();
144
+
145
+ // Track the component type for database deletion
146
+ entity.removedComponents.add(typeId);
147
+
148
+ // Remove the component from the map
149
+ entity.components.delete(typeId);
150
+ entity.setDirty(true);
151
+
152
+ // Fire component removed event. remove() stays sync to preserve
153
+ // the boolean return signature used by callers; attach .catch so
154
+ // async hook rejections do not escape (H-HOOK-1).
155
+ Promise.resolve()
156
+ .then(() => EntityHookManager.executeHooks(new ComponentRemovedEvent(entity, component)))
157
+ .catch((error) => {
158
+ logger.error(`Error firing component removed hook for ${typeId}: ${error}`);
159
+ });
160
+
161
+ // Invalidate DataLoader cache if context is provided
162
+ if (context?.loaders?.componentsByEntityType) {
163
+ context.loaders.componentsByEntityType.clear({
164
+ entityId: entity.id,
165
+ typeId: typeId
166
+ });
167
+ }
168
+
169
+ // Fire-and-forget cache invalidation, tracked for shutdown drain
170
+ // (H-CACHE-1).
171
+ trackCacheOp((async () => {
172
+ try {
173
+ const CacheManager = getCacheManager();
174
+ const cacheManager = CacheManager.getInstance();
175
+ const config = cacheManager.getConfig();
176
+
177
+ if (config.enabled && config.component?.enabled) {
178
+ await cacheManager.invalidateComponent(entity.id, typeId);
179
+ }
180
+ } catch (error) {
181
+ logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache invalidation failed after remove', err: error });
182
+ }
183
+ })());
184
+
185
+ return true;
186
+ }
187
+
188
+ return false;
189
+ }
190
+
191
+ 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> {
192
+ const comp = await loadComponent(entity, ctor, context);
193
+ return comp ? (comp as ComponentGetter<T>).data() : null;
194
+ }
195
+
196
+ export function has<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): boolean {
197
+ return hasInMemory(entity, ctor);
198
+ }
199
+
200
+ export async function getOrThrow<T extends BaseComponent>(
201
+ entity: Entity,
202
+ ctor: new (...args: any[]) => T,
203
+ context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }
204
+ ): Promise<ComponentDataType<T>> {
205
+ const data = await get(entity, ctor, context);
206
+ if (data === null) {
207
+ throw new Error(`Entity ${entity.id} is missing required component ${ctor.name}`);
208
+ }
209
+ return data;
210
+ }
211
+
212
+ export function getCached<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): ComponentDataType<T> | undefined {
213
+ const comp = getInMemory(entity, ctor);
214
+ return comp ? (comp as ComponentGetter<T>).data() : undefined;
215
+ }
216
+
217
+ 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> {
218
+ return loadComponent(entity, ctor, context);
219
+ }
220
+
221
+ export async function reload(entity: Entity, opts?: { trx?: SQL; signal?: AbortSignal }): Promise<Entity> {
222
+ if (!entity.id || entity.id.trim() === '') {
223
+ return entity;
224
+ }
225
+ entity.components.clear();
226
+ entity.removedComponents.clear();
227
+ entity.savedRemovedComponents.clear();
228
+ entity._missingComponents.clear();
229
+
230
+ const dbConn = opts?.trx ?? db;
231
+ const rows = await runWithSignal<any[]>(
232
+ dbConn`
233
+ SELECT c.id, c.type_id, c.data
234
+ FROM components c
235
+ WHERE c.entity_id = ${entity.id} AND c.deleted_at IS NULL
236
+ `,
237
+ opts?.signal
238
+ );
239
+
240
+ const storage = getMetadataStorage();
241
+ for (const row of rows) {
242
+ const ctor = ComponentRegistry.getConstructor(row.type_id);
243
+ if (!ctor) continue;
244
+ const comp: any = new ctor();
245
+ const parsed = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
246
+ Object.assign(comp, parsed);
247
+ comp.id = row.id;
248
+ const props = storage.componentProperties.get(row.type_id);
249
+ if (props) {
250
+ for (const prop of props) {
251
+ if (prop.propertyType === Date && typeof comp[prop.propertyKey] === 'string') {
252
+ comp[prop.propertyKey] = new Date(comp[prop.propertyKey]);
253
+ }
254
+ }
255
+ }
256
+ comp.setPersisted(true);
257
+ comp.setDirty(false);
258
+ addComponent(entity, comp);
259
+ }
260
+
261
+ entity.setPersisted(true);
262
+ entity.setDirty(false);
263
+ return entity;
264
+ }
265
+
266
+ export async function requireComponents(entity: Entity, ctors: Array<new (...args: any[]) => BaseComponent>): Promise<void> {
267
+ if (ctors.length === 0) return;
268
+ const missing: string[] = [];
269
+ for (const ctor of ctors) {
270
+ // components is keyed by type id — O(1) lookup, no instantiation
271
+ // and no O(K) instanceof scan per constructor.
272
+ const typeId = typeIdOf(ctor);
273
+ if (!entity.components.has(typeId)) {
274
+ missing.push(typeId);
275
+ }
276
+ }
277
+ if (missing.length === 0) return;
278
+ const { Entity } = await import("../Entity");
279
+ await Entity.LoadComponents([entity], missing);
280
+ }
281
+
282
+ async function loadComponent<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): Promise<T | null> {
283
+ const comp = entity.components.get(typeIdOf(ctor)) as T | undefined;
284
+ if (typeof comp !== "undefined") {
285
+ return comp;
286
+ }
287
+
288
+ // Validate entity ID before database query
289
+ if (!entity.id || entity.id.trim() === '') {
290
+ logger.warn(`Cannot load component ${ctor.name}: entity id is empty`);
291
+ return null;
292
+ }
293
+
294
+ // Memoized metadata lookup — no throwaway component instantiation
295
+ // just to read the type id.
296
+ const typeId = typeIdOf(ctor);
297
+
298
+ // Negative-cache short-circuit: if we previously confirmed this component
299
+ // is absent from the DB (and no explicit transaction is in scope that
300
+ // could see a different snapshot), skip the SELECT entirely.
301
+ // Skipped when a trx is provided — within a transaction the visibility
302
+ // horizon may differ from the outer read (stale-read hazard).
303
+ if (!context?.trx && entity._missingComponents.has(typeId)) {
304
+ return null;
305
+ }
306
+
307
+ // Use transaction if provided, otherwise use default db
308
+ const dbConn = context?.trx ?? db;
309
+
310
+ // Ambient request scope fallback: bare entity.get() calls (e.g.
311
+ // inside @ArcheTypeFunction bodies or Unwrap()) batch through the
312
+ // request's DataLoaders instead of firing one SELECT per call.
313
+ // Never substituted when the caller passed an explicit trx — a
314
+ // loader read outside the transaction could see stale data.
315
+ const scope = (!context?.loaders && !context?.trx) ? getRequestScope() : undefined;
316
+ const loaders = context?.loaders ?? scope?.loaders;
317
+ const signal = context?.signal ?? scope?.signal;
318
+
319
+ try {
320
+ let componentData: any = null;
321
+ let componentId: string | null = null;
322
+
323
+ if (loaders?.componentsByEntityType) {
324
+ const loaderResult = await loaders.componentsByEntityType.load({
325
+ entityId: entity.id,
326
+ typeId: typeId
327
+ });
328
+ if (loaderResult) {
329
+ componentData = loaderResult.data;
330
+ componentId = loaderResult.id;
331
+ }
332
+ } else {
333
+ // Route through runWithSignal so a request/wall-clock abort can
334
+ // cancel this in-flight read. When dbConn is context.trx, an
335
+ // uncancelled read leaks the backend into `idle in transaction`
336
+ // on timeout (matches the d1dde84 save/delete fix, which missed
337
+ // the read path).
338
+ const rows = await runWithSignal<any[]>(
339
+ dbConn`SELECT id, data FROM components WHERE entity_id = ${entity.id} AND type_id = ${typeId} AND deleted_at IS NULL`,
340
+ signal
341
+ );
342
+ if (rows.length > 0) {
343
+ componentData = rows[0].data;
344
+ componentId = rows[0].id;
345
+ }
346
+ }
347
+
348
+ if (componentData !== null) {
349
+ const comp: any = new ctor();
350
+ if (componentId) {
351
+ comp.id = componentId;
352
+ }
353
+ const parsedData = typeof componentData === 'string' ? JSON.parse(componentData) : componentData;
354
+ Object.assign(comp, parsedData);
355
+ const storage = getMetadataStorage();
356
+ const props = storage.componentProperties.get(typeId);
357
+ if (props) {
358
+ for (const prop of props) {
359
+ if (prop.propertyType === Date && typeof comp[prop.propertyKey] === 'string') {
360
+ comp[prop.propertyKey] = new Date(comp[prop.propertyKey]);
361
+ }
362
+ }
363
+ }
364
+ comp.setPersisted(true);
365
+ comp.setDirty(false);
366
+ addComponent(entity, comp);
367
+ return comp as T;
368
+ } else {
369
+ // Record the confirmed absence so repeated probes skip the DB.
370
+ // Only when no explicit trx — within a transaction the caller
371
+ // may insert the component and probe again in the same scope.
372
+ if (!context?.trx) {
373
+ entity._missingComponents.add(typeId);
374
+ }
375
+ return null;
376
+ }
377
+ } catch (error) {
378
+ logger.error(`Failed to fetch component ${ctor.name}: ${error}`);
379
+ return null;
380
+ }
381
+ }
382
+
383
+ export { loadComponent };