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
@@ -0,0 +1,155 @@
1
+ import type { BaseComponent } from "../components";
2
+ import type ArcheType from "../ArcheType";
3
+ import type { LifecycleEvent } from "../events/EntityLifecycleEvents";
4
+ import type { ComponentTargetConfig } from "./registry";
5
+ import { typeIdOfCtor } from "./registry";
6
+
7
+ /**
8
+ * Check if an event matches the component targeting configuration
9
+ */
10
+ export function matchesComponentTarget(event: LifecycleEvent, componentTarget?: ComponentTargetConfig): boolean {
11
+ // If no component targeting is specified, always match
12
+ if (!componentTarget) {
13
+ return true;
14
+ }
15
+
16
+ const entity = event.getEntity();
17
+ const entityComponents = entity.componentList();
18
+
19
+ // Check archetype matching first (most specific)
20
+ if (componentTarget.archetype) {
21
+ if (!matchesArchetype(entityComponents, componentTarget.archetype, !!(componentTarget.includeComponents?.length || componentTarget.excludeComponents?.length))) {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ // Check multiple archetypes (OR logic)
27
+ if (componentTarget.archetypes && componentTarget.archetypes.length > 0) {
28
+ const allowExtra = !!(componentTarget.includeComponents?.length || componentTarget.excludeComponents?.length);
29
+ const matchesAnyArchetype = componentTarget.archetypes.some(archetype =>
30
+ matchesArchetype(entityComponents, archetype, allowExtra)
31
+ );
32
+ if (!matchesAnyArchetype) {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ // Check included components
38
+ if (componentTarget.includeComponents && componentTarget.includeComponents.length > 0) {
39
+ const includeMatch = checkComponentPresence(
40
+ entityComponents,
41
+ componentTarget.includeComponents,
42
+ componentTarget.requireAllIncluded ?? true
43
+ );
44
+
45
+ if (!includeMatch) {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ // Check excluded components
51
+ if (componentTarget.excludeComponents && componentTarget.excludeComponents.length > 0) {
52
+ const excludeMatch = checkComponentAbsence(
53
+ entityComponents,
54
+ componentTarget.excludeComponents,
55
+ componentTarget.requireAllExcluded ?? true
56
+ );
57
+
58
+ if (!excludeMatch) {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ return true;
64
+ }
65
+
66
+ /**
67
+ * Check if required components are present on the entity
68
+ */
69
+ export function checkComponentPresence(
70
+ entityComponents: BaseComponent[],
71
+ requiredComponents: (new () => BaseComponent)[],
72
+ requireAll: boolean
73
+ ): boolean {
74
+ const entityComponentTypes = new Set(
75
+ entityComponents.map(comp => comp.getTypeID())
76
+ );
77
+
78
+ const requiredTypeIds = requiredComponents.map(typeIdOfCtor);
79
+
80
+ if (requireAll) {
81
+ // ALL required components must be present (AND logic)
82
+ return requiredTypeIds.every(typeId => entityComponentTypes.has(typeId));
83
+ } else {
84
+ // ANY required component must be present (OR logic)
85
+ return requiredTypeIds.some(typeId => entityComponentTypes.has(typeId));
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Check if excluded components are absent from the entity
91
+ */
92
+ export function checkComponentAbsence(
93
+ entityComponents: BaseComponent[],
94
+ excludedComponents: (new () => BaseComponent)[],
95
+ requireAll: boolean
96
+ ): boolean {
97
+ const entityComponentTypes = new Set(
98
+ entityComponents.map(comp => comp.getTypeID())
99
+ );
100
+
101
+ const excludedTypeIds = excludedComponents.map(typeIdOfCtor);
102
+
103
+ if (requireAll) {
104
+ // ALL excluded components must be absent (AND logic)
105
+ return excludedTypeIds.every(typeId => !entityComponentTypes.has(typeId));
106
+ } else {
107
+ // ANY excluded component must be absent (OR logic) - this is less common but supported
108
+ return excludedTypeIds.some(typeId => !entityComponentTypes.has(typeId));
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Check if entity components match a specific archetype
114
+ */
115
+ export function matchesArchetype(entityComponents: BaseComponent[], archetype: ArcheType, allowExtraComponents: boolean = false): boolean {
116
+ // Get the expected component types from the archetype
117
+ // We need to access the private componentMap from ArcheType
118
+ const archetypeComponentMap = (archetype as any).componentMap as Record<string, typeof BaseComponent>;
119
+
120
+ if (!archetypeComponentMap) {
121
+ return false;
122
+ }
123
+
124
+ const expectedComponentTypes = new Set(
125
+ Object.values(archetypeComponentMap).map(compCtor => typeIdOfCtor(compCtor as any))
126
+ );
127
+
128
+ const entityComponentTypes = new Set(
129
+ entityComponents.map(comp => comp.getTypeID())
130
+ );
131
+
132
+ if (allowExtraComponents) {
133
+ // Entity must have at least all the component types from the archetype
134
+ // (allows additional components beyond the archetype)
135
+ for (const expectedType of expectedComponentTypes) {
136
+ if (!entityComponentTypes.has(expectedType)) {
137
+ return false;
138
+ }
139
+ }
140
+ return true;
141
+ } else {
142
+ // Entity must have exactly the same component types as the archetype
143
+ if (expectedComponentTypes.size !== entityComponentTypes.size) {
144
+ return false;
145
+ }
146
+
147
+ // All expected component types must be present in the entity
148
+ for (const expectedType of expectedComponentTypes) {
149
+ if (!entityComponentTypes.has(expectedType)) {
150
+ return false;
151
+ }
152
+ }
153
+ return true;
154
+ }
155
+ }
@@ -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,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
+ }