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,233 @@
1
+ import { type CacheProvider } from '../CacheProvider';
2
+ import { type CacheConfig } from '../../../config/cache.config';
3
+ import { logger } from '../../Logger';
4
+ import type { Entity } from '../../Entity';
5
+ import type { BaseComponent } from '../../components';
6
+ import type { ComponentData } from '../../RequestLoaders';
7
+
8
+ // Must match the value exported by CacheManager — inlined here to avoid
9
+ // a circular import (CacheManager imports this module).
10
+ const COMPONENT_TOMBSTONE = '__TOMBSTONE__' as const;
11
+ type ComponentCacheValue = ComponentData | typeof COMPONENT_TOMBSTONE;
12
+
13
+ /**
14
+ * Write-through strategy: entity get/set operations
15
+ */
16
+
17
+ export async function getEntity(provider: CacheProvider, config: CacheConfig, id: string): Promise<string | null> {
18
+ if (!config.enabled || !config.entity?.enabled) {
19
+ return null;
20
+ }
21
+
22
+ try {
23
+ const key = `entity:${id}`;
24
+ const result = await provider.get<string>(key);
25
+ return result || null;
26
+ } catch (error) {
27
+ logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting entity from cache', error });
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export async function setEntityWriteThrough(provider: CacheProvider, config: CacheConfig, entity: Entity, ttl?: number): Promise<void> {
33
+ if (!config.enabled || !config.entity?.enabled) {
34
+ return;
35
+ }
36
+
37
+ try {
38
+ const key = `entity:${entity.id}`;
39
+ const effectiveTTL = ttl ?? config.entity.ttl;
40
+ // Only cache entity ID for existence check
41
+ await provider.set(key, entity.id, effectiveTTL);
42
+ } catch (error) {
43
+ logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting entity in cache', error });
44
+ }
45
+ }
46
+
47
+ export async function getEntities(provider: CacheProvider, config: CacheConfig, ids: string[]): Promise<(string | null)[]> {
48
+ if (!config.enabled || !config.entity?.enabled) {
49
+ return ids.map(() => null);
50
+ }
51
+
52
+ try {
53
+ const cacheKeys = ids.map(id => `entity:${id}`);
54
+ const results = await provider.getMany<string>(cacheKeys);
55
+ return results;
56
+ } catch (error) {
57
+ logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting entities from cache', error });
58
+ return ids.map(() => null);
59
+ }
60
+ }
61
+
62
+ export async function setEntitiesWriteThrough(provider: CacheProvider, config: CacheConfig, entities: Entity[], ttl?: number): Promise<void> {
63
+ if (!config.enabled || !config.entity?.enabled) {
64
+ return;
65
+ }
66
+
67
+ try {
68
+ const effectiveTTL = ttl ?? config.entity?.ttl;
69
+ const entries = entities.map(entity => ({
70
+ key: `entity:${entity.id}`,
71
+ // Only cache entity ID for existence check
72
+ value: entity.id,
73
+ ttl: effectiveTTL
74
+ }));
75
+ await provider.setMany(entries);
76
+ } catch (error) {
77
+ logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting entities in cache', error });
78
+ }
79
+ }
80
+
81
+ export async function getComponentsByEntity(provider: CacheProvider, config: CacheConfig, entityId: string, componentType?: string): Promise<BaseComponent[] | null> {
82
+ if (!config.enabled || !config.component?.enabled) {
83
+ return null;
84
+ }
85
+
86
+ try {
87
+ const key = componentType
88
+ ? `component:${entityId}:${componentType}`
89
+ : `components:${entityId}`;
90
+ return await provider.get<BaseComponent[]>(key);
91
+ } catch (error) {
92
+ logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting components from cache', error });
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Set components for an entity in cache with write-through strategy.
99
+ * Converts BaseComponent instances to ComponentData format for cache compatibility with DataLoader.
100
+ * Delegates to setComponentsBatchWriteThrough for a single-entity, 2-RTT batch.
101
+ */
102
+ export async function setComponentWriteThrough(provider: CacheProvider, config: CacheConfig, entityId: string, components: BaseComponent[], componentType?: string, ttl?: number): Promise<void> {
103
+ if (!config.enabled || !config.component?.enabled) {
104
+ return;
105
+ }
106
+ const entries = components.map(c => ({
107
+ entityId,
108
+ typeId: componentType || c.getTypeID(),
109
+ component: c,
110
+ ttl,
111
+ }));
112
+ await setComponentsBatchWriteThrough(provider, config, entries);
113
+ }
114
+
115
+ /**
116
+ * Batch write-through for BaseComponent instances across any number of
117
+ * entities. Performs exactly 2 Redis round-trips regardless of entry count:
118
+ * 1. pipelined getMany — reads existing entries to preserve createdAt (H-CACHE-3)
119
+ * 2. pipelined setMany — writes all updated entries
120
+ */
121
+ export async function setComponentsBatchWriteThrough(
122
+ provider: CacheProvider,
123
+ config: CacheConfig,
124
+ entries: Array<{ entityId: string; typeId: string; component: BaseComponent; ttl?: number }>,
125
+ ): Promise<void> {
126
+ if (!config.enabled || !config.component?.enabled || entries.length === 0) {
127
+ return;
128
+ }
129
+
130
+ try {
131
+ const effectiveTTL = config.component.ttl;
132
+ const keys = entries.map(e => `component:${e.entityId}:${e.typeId}`);
133
+
134
+ // One batched read — preserves createdAt from existing entries (H-CACHE-3).
135
+ const existing = await provider.getMany<ComponentData>(keys);
136
+
137
+ const now = new Date();
138
+ const setEntries = entries.map((e, i) => {
139
+ const prev = existing[i];
140
+ const createdAt: Date =
141
+ prev && prev.createdAt
142
+ ? (prev.createdAt instanceof Date ? prev.createdAt : new Date(prev.createdAt))
143
+ : now;
144
+
145
+ const componentData: ComponentData = {
146
+ id: e.component.id,
147
+ entityId: e.entityId,
148
+ typeId: e.typeId,
149
+ data: e.component.data(),
150
+ createdAt,
151
+ updatedAt: now,
152
+ deletedAt: null,
153
+ };
154
+
155
+ return { key: keys[i]!, value: componentData, ttl: e.ttl ?? effectiveTTL };
156
+ });
157
+
158
+ // One batched write.
159
+ await provider.setMany(setEntries);
160
+ } catch (error) {
161
+ logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting components in cache (batch)', err: error });
162
+ }
163
+ }
164
+
165
+ export async function getComponents(provider: CacheProvider, config: CacheConfig, keys: Array<{ entityId: string; typeId: string }>): Promise<(ComponentCacheValue | null)[]> {
166
+ if (!config.enabled || !config.component?.enabled) {
167
+ return keys.map(() => null);
168
+ }
169
+
170
+ try {
171
+ const cacheKeys = keys.map(k => `component:${k.entityId}:${k.typeId}`);
172
+ const results = await provider.getMany<ComponentCacheValue>(cacheKeys);
173
+ return results;
174
+ } catch (error) {
175
+ logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting components from cache', error });
176
+ return keys.map(() => null);
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Set components in cache with write-through strategy (for DataLoader integration).
182
+ *
183
+ * When `requestedKeys` is supplied and `component.negativeCacheEnabled` is
184
+ * true, tombstones are written for any requested key not present in
185
+ * `components` (within the same setMany call — single round-trip).
186
+ */
187
+ export async function setComponentsWriteThrough(
188
+ provider: CacheProvider,
189
+ config: CacheConfig,
190
+ components: ComponentData[],
191
+ ttlOrRequested?: number | Array<{ entityId: string; typeId: string }>,
192
+ ttlIfRequested?: number,
193
+ ): Promise<void> {
194
+ if (!config.enabled || !config.component?.enabled) {
195
+ return;
196
+ }
197
+
198
+ // Backward-compatible overload: (components, ttl?) or (components, requestedKeys, ttl?)
199
+ const requestedKeys = Array.isArray(ttlOrRequested) ? ttlOrRequested : undefined;
200
+ const ttl = Array.isArray(ttlOrRequested) ? ttlIfRequested : ttlOrRequested;
201
+
202
+ try {
203
+ const componentTTL = ttl ?? config.component.ttl;
204
+ const entries: Array<{ key: string; value: ComponentCacheValue; ttl: number }> = components.map(comp => ({
205
+ key: `component:${comp.entityId}:${comp.typeId}`,
206
+ value: comp,
207
+ ttl: componentTTL,
208
+ }));
209
+
210
+ const negativeEnabled = config.component.negativeCacheEnabled === true;
211
+ if (negativeEnabled && requestedKeys && requestedKeys.length > 0) {
212
+ const found = new Set(components.map(c => `${c.entityId}-${c.typeId}`));
213
+ const tombstoneTTL = config.component.negativeCacheTtl
214
+ ?? Math.min(componentTTL, 60_000);
215
+ for (const k of requestedKeys) {
216
+ const dedupeKey = `${k.entityId}-${k.typeId}`;
217
+ if (!found.has(dedupeKey)) {
218
+ entries.push({
219
+ key: `component:${k.entityId}:${k.typeId}`,
220
+ value: COMPONENT_TOMBSTONE,
221
+ ttl: tombstoneTTL,
222
+ });
223
+ }
224
+ }
225
+ }
226
+
227
+ if (entries.length > 0) {
228
+ await provider.setMany(entries);
229
+ }
230
+ } catch (error) {
231
+ logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting components in cache', error });
232
+ }
233
+ }
@@ -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
+ }