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,247 @@
1
+ import type { BaseComponent } from "../components";
2
+ import type ArcheType from "../ArcheType";
3
+ import type { EntityEvent, ComponentEvent, LifecycleEvent } from "../events/EntityLifecycleEvents";
4
+ import { getMetadataStorage } from "../metadata";
5
+
6
+ // Memoized constructor → typeId. Hook matching runs on every save event for
7
+ // every hook filter; instantiating components (`new compCtor()`) per check
8
+ // was O(hooks × filters) constructor calls per event.
9
+ const typeIdCache = new Map<Function, string>();
10
+ export function typeIdOfCtor(compCtor: new () => BaseComponent): string {
11
+ let id = typeIdCache.get(compCtor);
12
+ if (id === undefined) {
13
+ id = getMetadataStorage().getComponentId(compCtor.name);
14
+ typeIdCache.set(compCtor, id);
15
+ }
16
+ return id;
17
+ }
18
+
19
+ /**
20
+ * Hook callback function signature for entity events
21
+ */
22
+ export type EntityHookCallback<T extends EntityEvent = EntityEvent> = (event: T) => void;
23
+
24
+ /**
25
+ * Hook callback function signature for component events
26
+ */
27
+ export type ComponentHookCallback<T extends ComponentEvent = ComponentEvent> = (event: T) => void;
28
+
29
+ /**
30
+ * Hook callback function signature for any lifecycle event
31
+ */
32
+ export type LifecycleHookCallback = (event: LifecycleEvent) => void;
33
+
34
+ /**
35
+ * Component targeting configuration for hooks
36
+ */
37
+ export interface ComponentTargetConfig {
38
+ /** Component types that must be present on the entity for the hook to execute */
39
+ includeComponents?: (new () => BaseComponent)[];
40
+ /** Component types that must NOT be present on the entity for the hook to execute */
41
+ excludeComponents?: (new () => BaseComponent)[];
42
+ /** Whether to require ALL included components (AND) or ANY included component (OR) */
43
+ requireAllIncluded?: boolean;
44
+ /** Whether to require ALL excluded components to be absent (AND) or ANY excluded component to be absent (OR) */
45
+ requireAllExcluded?: boolean;
46
+ /** Archetype to match - entity must have exactly these component types */
47
+ archetype?: ArcheType;
48
+ /** Archetypes to match - entity must match ANY of these archetypes */
49
+ archetypes?: ArcheType[];
50
+ }
51
+
52
+ /**
53
+ * Hook registration options
54
+ */
55
+ export interface HookOptions {
56
+ /** Priority for hook execution order (higher numbers execute first) */
57
+ priority?: number;
58
+ /** Optional name for the hook for debugging */
59
+ name?: string;
60
+ /** Whether the hook should be executed asynchronously */
61
+ async?: boolean;
62
+ /** Filter function to conditionally execute the hook */
63
+ filter?: (event: LifecycleEvent) => boolean;
64
+ /** Maximum execution time in milliseconds (for timeout handling) */
65
+ timeout?: number;
66
+ /** Component targeting configuration for fine-grained hook execution */
67
+ componentTarget?: ComponentTargetConfig;
68
+ }
69
+
70
+ /**
71
+ * Registered hook information
72
+ */
73
+ export interface RegisteredHook {
74
+ callback: LifecycleHookCallback;
75
+ options: HookOptions;
76
+ id: string;
77
+ }
78
+
79
+ /**
80
+ * Hook execution metrics
81
+ */
82
+ export interface HookMetrics {
83
+ totalExecutions: number;
84
+ totalExecutionTime: number;
85
+ averageExecutionTime: number;
86
+ errorCount: number;
87
+ lastExecutionTime: number;
88
+ }
89
+
90
+ /**
91
+ * Registry state owned by the manager instance
92
+ */
93
+ export interface RegistryState {
94
+ hooks: Map<string, RegisteredHook[]>;
95
+ hookCounter: number;
96
+ }
97
+
98
+ /**
99
+ * Create initial registry state
100
+ */
101
+ export function createRegistryState(): RegistryState {
102
+ return {
103
+ hooks: new Map(),
104
+ hookCounter: 0
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Generate a unique hook ID
110
+ */
111
+ export function generateHookId(state: RegistryState): string {
112
+ return `hook_${++state.hookCounter}_${Date.now()}`;
113
+ }
114
+
115
+ /**
116
+ * Sort hooks by priority (higher priority first)
117
+ */
118
+ export function sortHooksByPriority(state: RegistryState, eventType: string): void {
119
+ const hooks = state.hooks.get(eventType);
120
+ if (hooks) {
121
+ hooks.sort((a, b) => (b.options.priority || 0) - (a.options.priority || 0));
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Register a hook for entity lifecycle events
127
+ */
128
+ export function registerEntityHook<T extends EntityEvent>(
129
+ state: RegistryState,
130
+ eventType: T['eventType'],
131
+ callback: EntityHookCallback<T>,
132
+ options: HookOptions
133
+ ): string {
134
+ const hookId = generateHookId(state);
135
+ const hook: RegisteredHook = {
136
+ callback: callback as LifecycleHookCallback,
137
+ options: { priority: 0, ...options },
138
+ id: hookId
139
+ };
140
+
141
+ if (!state.hooks.has(eventType)) {
142
+ state.hooks.set(eventType, []);
143
+ }
144
+
145
+ state.hooks.get(eventType)!.push(hook);
146
+ sortHooksByPriority(state, eventType);
147
+
148
+ return hookId;
149
+ }
150
+
151
+ /**
152
+ * Register a hook for component lifecycle events
153
+ */
154
+ export function registerComponentHook<T extends ComponentEvent>(
155
+ state: RegistryState,
156
+ eventType: T['eventType'],
157
+ callback: ComponentHookCallback<T>,
158
+ options: HookOptions
159
+ ): string {
160
+ const hookId = generateHookId(state);
161
+ const hook: RegisteredHook = {
162
+ callback: callback as LifecycleHookCallback,
163
+ options: { priority: 0, ...options },
164
+ id: hookId
165
+ };
166
+
167
+ if (!state.hooks.has(eventType)) {
168
+ state.hooks.set(eventType, []);
169
+ }
170
+
171
+ state.hooks.get(eventType)!.push(hook);
172
+ sortHooksByPriority(state, eventType);
173
+
174
+ return hookId;
175
+ }
176
+
177
+ /**
178
+ * Register a hook for all lifecycle events
179
+ */
180
+ export function registerLifecycleHook(
181
+ state: RegistryState,
182
+ callback: LifecycleHookCallback,
183
+ options: HookOptions
184
+ ): string {
185
+ const hookId = generateHookId(state);
186
+ const hook: RegisteredHook = {
187
+ callback,
188
+ options: { priority: 0, ...options },
189
+ id: hookId
190
+ };
191
+
192
+ // Register for all event types
193
+ const allEventTypes = [
194
+ "entity.created", "entity.updated", "entity.deleted",
195
+ "component.added", "component.updated", "component.removed"
196
+ ];
197
+
198
+ for (const eventType of allEventTypes) {
199
+ if (!state.hooks.has(eventType)) {
200
+ state.hooks.set(eventType, []);
201
+ }
202
+ state.hooks.get(eventType)!.push({ ...hook }); // Clone hook for each event type
203
+ }
204
+
205
+ return hookId;
206
+ }
207
+
208
+ /**
209
+ * Remove a hook by its ID
210
+ */
211
+ export function removeHook(state: RegistryState, hookId: string): boolean {
212
+ let removed = false;
213
+
214
+ for (const [eventType, hooks] of state.hooks.entries()) {
215
+ const initialLength = hooks.length;
216
+ state.hooks.set(eventType, hooks.filter(hook => hook.id !== hookId));
217
+
218
+ if (state.hooks.get(eventType)!.length < initialLength) {
219
+ removed = true;
220
+ }
221
+ }
222
+
223
+ return removed;
224
+ }
225
+
226
+ /**
227
+ * Get the number of registered hooks for an event type
228
+ */
229
+ export function getHookCount(state: RegistryState, eventType?: string): number {
230
+ if (eventType) {
231
+ return state.hooks.get(eventType)?.length || 0;
232
+ }
233
+
234
+ let total = 0;
235
+ for (const hooks of state.hooks.values()) {
236
+ total += hooks.length;
237
+ }
238
+ return total;
239
+ }
240
+
241
+ /**
242
+ * Clear all hooks
243
+ */
244
+ export function clearAllHooks(state: RegistryState): void {
245
+ state.hooks.clear();
246
+ state.hookCounter = 0;
247
+ }
@@ -20,6 +20,6 @@ export interface ComponentPropertyMetadata {
20
20
  export interface IndexedFieldMetadata {
21
21
  componentId: string;
22
22
  propertyKey: string;
23
- indexType: 'gin' | 'btree' | 'hash' | 'numeric';
23
+ indexType: 'gin' | 'btree' | 'hash' | 'numeric' | 'fulltext';
24
24
  isDateField: boolean;
25
25
  }
@@ -3,6 +3,11 @@ import { getMetadataStorage } from "./getMetadataStorage";
3
3
 
4
4
  export { getMetadataStorage } from "./getMetadataStorage";
5
5
 
6
+ // Cached after first call — metadata is fixed after startup so serialization
7
+ // cost is paid only once regardless of how many /studio navigations occur.
8
+ let _metadataCache: ReturnType<typeof getSerializedMetadataStorage> | undefined;
9
+ let _metadataScriptCache: string | undefined;
10
+
6
11
  function toFieldLabel(fieldName: string): string {
7
12
  let label = fieldName.replace(/_/g, ' ');
8
13
  label = label.split(' ').map(word => word === 'id' ? 'ID' : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
@@ -20,6 +25,8 @@ export function getSerializedMetadataStorage(): {
20
25
  }[]
21
26
  >;
22
27
  } {
28
+ if (_metadataCache) return _metadataCache;
29
+
23
30
  const storage = getMetadataStorage();
24
31
  const archeTypes: Record<string, any> = {};
25
32
 
@@ -34,11 +41,15 @@ export function getSerializedMetadataStorage(): {
34
41
  });
35
42
  });
36
43
 
37
- // console.log(archeTypes, 'archeTypes');
44
+ _metadataCache = { archeTypes };
45
+ return _metadataCache;
46
+ }
38
47
 
39
- return {
40
- archeTypes,
41
- };
48
+ /** Returns the pre-serialized `<script>` tag for studio injection. */
49
+ export function getMetadataScript(): string {
50
+ if (_metadataScriptCache) return _metadataScriptCache;
51
+ _metadataScriptCache = `<script>window.bunsaneMetadata = ${JSON.stringify(getSerializedMetadataStorage())};</script>`;
52
+ return _metadataScriptCache;
42
53
  }
43
54
 
44
55
  export function Enum() {
@@ -1,6 +1,7 @@
1
1
  import type { Middleware } from '../Middleware';
2
2
  import { logger as MainLogger } from '../Logger';
3
3
  import { getRequestId } from './RequestId';
4
+ import type { RequestStats } from '../RequestContext';
4
5
 
5
6
  const logger = MainLogger.child({ scope: 'HTTP' });
6
7
 
@@ -37,7 +38,8 @@ export function accessLog(options: AccessLogOptions = {}): Middleware {
37
38
  }
38
39
 
39
40
  const duration = Math.round(performance.now() - start);
40
- const logData = {
41
+ const stats = (req as any).__bunsaneStats as RequestStats | undefined;
42
+ const logData: Record<string, any> = {
41
43
  requestId: getRequestId(),
42
44
  method: req.method,
43
45
  path: url.pathname,
@@ -45,6 +47,11 @@ export function accessLog(options: AccessLogOptions = {}): Middleware {
45
47
  duration,
46
48
  msg: `${req.method} ${url.pathname} ${response.status} ${duration}ms`,
47
49
  };
50
+ if (stats) {
51
+ logData.operationName = stats.operationName;
52
+ logData.dataLoaderCalls = stats.dataLoaderCalls;
53
+ logData.dbQueryCount = stats.dbQueryCount;
54
+ }
48
55
 
49
56
  if (response.status >= 500) {
50
57
  logger.error(logData);
@@ -1,105 +1,102 @@
1
- import type { Middleware } from '../Middleware';
2
- import { logger as MainLogger } from '../Logger';
3
-
4
- const logger = MainLogger.child({ scope: 'RateLimit' });
5
-
6
- export type RateLimitOptions = {
7
- /** Maximum requests in the window. Default: 100 */
8
- max?: number;
9
- /** Window length in milliseconds. Default: 60_000 (1 min) */
10
- windowMs?: number;
11
- /** Only apply to paths matching this prefix list. Default: all */
12
- pathPrefixes?: string[];
13
- /** Extract client key (override default: X-Forwarded-For → remote). */
14
- keyExtractor?: (req: Request) => string;
15
- /** Response status for rejection. Default: 429 */
16
- status?: number;
17
- /** Trust X-Forwarded-For header. Default: false */
18
- trustProxy?: boolean;
19
- };
20
-
21
- type Bucket = {
22
- count: number;
23
- resetAt: number;
24
- };
25
-
26
- /**
27
- * In-memory token-bucket rate limiter. Per-instance only — for multi-instance
28
- * deployments use a shared Redis-backed limiter. Sweeps expired buckets on
29
- * each increment to keep memory bounded.
30
- */
31
- export function rateLimit(options: RateLimitOptions = {}): Middleware {
32
- const max = options.max ?? 100;
33
- const windowMs = options.windowMs ?? 60_000;
34
- const pathPrefixes = options.pathPrefixes;
35
- const status = options.status ?? 429;
36
- const trustProxy = options.trustProxy ?? false;
37
- const keyExtractor = options.keyExtractor ?? ((req: Request) => {
38
- if (trustProxy) {
39
- const xff = req.headers.get('x-forwarded-for');
40
- if (xff) return xff.split(',')[0]!.trim();
41
- }
42
- const realIp = req.headers.get('x-real-ip');
43
- if (realIp) return realIp;
44
- return 'anonymous';
45
- });
46
-
47
- const buckets = new Map<string, Bucket>();
48
- let lastSweep = Date.now();
49
-
50
- return async (req, next) => {
51
- if (pathPrefixes && pathPrefixes.length > 0) {
52
- const url = new URL(req.url);
53
- const match = pathPrefixes.some((p) => url.pathname.startsWith(p));
54
- if (!match) return next();
55
- }
56
-
57
- const now = Date.now();
58
- const key = keyExtractor(req);
59
-
60
- if (now - lastSweep > windowMs) {
61
- for (const [k, v] of buckets) {
62
- if (v.resetAt <= now) buckets.delete(k);
63
- }
64
- lastSweep = now;
65
- }
66
-
67
- let bucket = buckets.get(key);
68
- if (!bucket || bucket.resetAt <= now) {
69
- bucket = { count: 0, resetAt: now + windowMs };
70
- buckets.set(key, bucket);
71
- }
72
-
73
- bucket.count++;
74
- const remaining = Math.max(0, max - bucket.count);
75
- const retryAfterSec = Math.ceil((bucket.resetAt - now) / 1000);
76
-
77
- if (bucket.count > max) {
78
- logger.warn({ key, path: new URL(req.url).pathname, count: bucket.count, max }, 'rate limit exceeded');
79
- return new Response(
80
- JSON.stringify({ error: 'Too many requests', retryAfter: retryAfterSec }),
81
- {
82
- status,
83
- headers: {
84
- 'Content-Type': 'application/json',
85
- 'Retry-After': String(retryAfterSec),
86
- 'X-RateLimit-Limit': String(max),
87
- 'X-RateLimit-Remaining': '0',
88
- 'X-RateLimit-Reset': String(Math.floor(bucket.resetAt / 1000)),
89
- },
90
- },
91
- );
92
- }
93
-
94
- const response = await next();
95
- const newHeaders = new Headers(response.headers);
96
- newHeaders.set('X-RateLimit-Limit', String(max));
97
- newHeaders.set('X-RateLimit-Remaining', String(remaining));
98
- newHeaders.set('X-RateLimit-Reset', String(Math.floor(bucket.resetAt / 1000)));
99
- return new Response(response.body, {
100
- status: response.status,
101
- statusText: response.statusText,
102
- headers: newHeaders,
103
- });
104
- };
105
- }
1
+ import type { Middleware } from '../Middleware';
2
+ import { logger as MainLogger } from '../Logger';
3
+ import { setResponseHeaders } from './headers';
4
+
5
+ const logger = MainLogger.child({ scope: 'RateLimit' });
6
+
7
+ export type RateLimitOptions = {
8
+ /** Maximum requests in the window. Default: 100 */
9
+ max?: number;
10
+ /** Window length in milliseconds. Default: 60_000 (1 min) */
11
+ windowMs?: number;
12
+ /** Only apply to paths matching this prefix list. Default: all */
13
+ pathPrefixes?: string[];
14
+ /** Extract client key (override default: X-Forwarded-For → remote). */
15
+ keyExtractor?: (req: Request) => string;
16
+ /** Response status for rejection. Default: 429 */
17
+ status?: number;
18
+ /** Trust X-Forwarded-For header. Default: false */
19
+ trustProxy?: boolean;
20
+ };
21
+
22
+ type Bucket = {
23
+ count: number;
24
+ resetAt: number;
25
+ };
26
+
27
+ /**
28
+ * In-memory token-bucket rate limiter. Per-instance only for multi-instance
29
+ * deployments use a shared Redis-backed limiter. Sweeps expired buckets on
30
+ * each increment to keep memory bounded.
31
+ */
32
+ export function rateLimit(options: RateLimitOptions = {}): Middleware {
33
+ const max = options.max ?? 100;
34
+ const windowMs = options.windowMs ?? 60_000;
35
+ const pathPrefixes = options.pathPrefixes;
36
+ const status = options.status ?? 429;
37
+ const trustProxy = options.trustProxy ?? false;
38
+ const keyExtractor = options.keyExtractor ?? ((req: Request) => {
39
+ if (trustProxy) {
40
+ const xff = req.headers.get('x-forwarded-for');
41
+ if (xff) return xff.split(',')[0]!.trim();
42
+ }
43
+ const realIp = req.headers.get('x-real-ip');
44
+ if (realIp) return realIp;
45
+ return 'anonymous';
46
+ });
47
+
48
+ const buckets = new Map<string, Bucket>();
49
+ let lastSweep = Date.now();
50
+
51
+ return async (req, next) => {
52
+ if (pathPrefixes && pathPrefixes.length > 0) {
53
+ const url = new URL(req.url);
54
+ const match = pathPrefixes.some((p) => url.pathname.startsWith(p));
55
+ if (!match) return next();
56
+ }
57
+
58
+ const now = Date.now();
59
+ const key = keyExtractor(req);
60
+
61
+ if (now - lastSweep > windowMs) {
62
+ for (const [k, v] of buckets) {
63
+ if (v.resetAt <= now) buckets.delete(k);
64
+ }
65
+ lastSweep = now;
66
+ }
67
+
68
+ let bucket = buckets.get(key);
69
+ if (!bucket || bucket.resetAt <= now) {
70
+ bucket = { count: 0, resetAt: now + windowMs };
71
+ buckets.set(key, bucket);
72
+ }
73
+
74
+ bucket.count++;
75
+ const remaining = Math.max(0, max - bucket.count);
76
+ const retryAfterSec = Math.ceil((bucket.resetAt - now) / 1000);
77
+
78
+ if (bucket.count > max) {
79
+ logger.warn({ key, path: new URL(req.url).pathname, count: bucket.count, max }, 'rate limit exceeded');
80
+ return new Response(
81
+ JSON.stringify({ error: 'Too many requests', retryAfter: retryAfterSec }),
82
+ {
83
+ status,
84
+ headers: {
85
+ 'Content-Type': 'application/json',
86
+ 'Retry-After': String(retryAfterSec),
87
+ 'X-RateLimit-Limit': String(max),
88
+ 'X-RateLimit-Remaining': '0',
89
+ 'X-RateLimit-Reset': String(Math.floor(bucket.resetAt / 1000)),
90
+ },
91
+ },
92
+ );
93
+ }
94
+
95
+ const response = await next();
96
+ return setResponseHeaders(response, [
97
+ ['X-RateLimit-Limit', String(max)],
98
+ ['X-RateLimit-Remaining', String(remaining)],
99
+ ['X-RateLimit-Reset', String(Math.floor(bucket.resetAt / 1000))],
100
+ ]);
101
+ };
102
+ }
@@ -1,5 +1,6 @@
1
1
  import { AsyncLocalStorage } from 'async_hooks';
2
2
  import type { Middleware } from '../Middleware';
3
+ import { setResponseHeaders } from './headers';
3
4
 
4
5
  /**
5
6
  * AsyncLocalStorage to propagate requestId to any code running within a request.
@@ -24,15 +25,7 @@ export function requestId(): Middleware {
24
25
 
25
26
  return requestStore.run({ requestId: id }, async () => {
26
27
  const response = await next();
27
-
28
- const newHeaders = new Headers(response.headers);
29
- newHeaders.set('X-Request-Id', id);
30
-
31
- return new Response(response.body, {
32
- status: response.status,
33
- statusText: response.statusText,
34
- headers: newHeaders,
35
- });
28
+ return setResponseHeaders(response, [['X-Request-Id', id]]);
36
29
  });
37
30
  };
38
31
  }
@@ -1,4 +1,5 @@
1
1
  import type { Middleware } from '../Middleware';
2
+ import { setResponseHeaders } from './headers';
2
3
 
3
4
  export type SecurityHeadersOptions = {
4
5
  /** Enable HSTS header. Default: true in production */
@@ -47,16 +48,6 @@ export function securityHeaders(options: SecurityHeadersOptions = {}): Middlewar
47
48
 
48
49
  return async (req, next) => {
49
50
  const response = await next();
50
-
51
- const newHeaders = new Headers(response.headers);
52
- for (const [key, value] of headersToSet) {
53
- newHeaders.set(key, value);
54
- }
55
-
56
- return new Response(response.body, {
57
- status: response.status,
58
- statusText: response.statusText,
59
- headers: newHeaders,
60
- });
51
+ return setResponseHeaders(response, headersToSet);
61
52
  };
62
53
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Attempt to mutate `response.headers` in-place.
3
+ * Constructed Responses (Yoga, REST handlers) allow mutation; only proxied
4
+ * fetch Responses guard their headers as immutable. Fall back to a single
5
+ * clone so the chain never needs more than one new Response.
6
+ */
7
+ export function setResponseHeaders(
8
+ response: Response,
9
+ headers: Iterable<[string, string]>,
10
+ ): Response {
11
+ try {
12
+ for (const [key, value] of headers) {
13
+ response.headers.set(key, value);
14
+ }
15
+ return response;
16
+ } catch {
17
+ // Immutable guard hit (e.g. proxied fetch Response) — clone once.
18
+ const cloned = new Headers(response.headers);
19
+ for (const [key, value] of headers) {
20
+ cloned.set(key, value);
21
+ }
22
+ return new Response(response.body, {
23
+ status: response.status,
24
+ statusText: response.statusText,
25
+ headers: cloned,
26
+ });
27
+ }
28
+ }