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.
- package/CHANGELOG.md +445 -318
- package/config/cache.config.ts +35 -1
- package/core/App.ts +24 -1064
- package/core/ArcheType.ts +78 -2110
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +85 -1043
- package/core/EntityHookManager.ts +52 -754
- package/core/Logger.ts +10 -0
- package/core/RequestContext.ts +64 -6
- package/core/RequestLoaders.ts +187 -36
- package/core/SchedulerManager.ts +28 -600
- package/core/app/bootstrap.ts +133 -0
- package/core/app/cors.ts +85 -0
- package/core/app/graphqlSetup.ts +56 -0
- package/core/app/healthEndpoints.ts +31 -0
- package/core/app/metricsCollector.ts +27 -0
- package/core/app/preparedStatementWarmup.ts +15 -0
- package/core/app/processHandlers.ts +43 -0
- package/core/app/requestRouter.ts +310 -0
- package/core/app/restRegistry.ts +80 -0
- package/core/app/shutdown.ts +97 -0
- package/core/app/studioRouter.ts +83 -0
- package/core/archetype/customTypes.ts +100 -0
- package/core/archetype/decorators.ts +171 -0
- package/core/archetype/fieldResolvers.ts +666 -0
- package/core/archetype/helpers.ts +29 -0
- package/core/archetype/relationLoader.ts +161 -0
- package/core/archetype/schemaBuilder.ts +141 -0
- package/core/archetype/weaver.ts +218 -0
- package/core/archetype/zodSchemaBuilder.ts +527 -0
- package/core/cache/CacheManager.ts +173 -267
- package/core/cache/CompressionUtils.ts +34 -3
- package/core/cache/MemoryCache.ts +40 -37
- package/core/cache/RedisCache.ts +4 -4
- package/core/cache/health.ts +30 -0
- package/core/cache/invalidation.ts +96 -0
- package/core/cache/strategies/writeInvalidate.ts +111 -0
- package/core/cache/strategies/writeThrough.ts +233 -0
- package/core/components/BaseComponent.ts +16 -8
- package/core/components/ComponentRegistry.ts +28 -0
- package/core/decorators/IndexedField.ts +1 -1
- package/core/entity/cacheStrategies.ts +97 -0
- package/core/entity/componentAccess.ts +364 -0
- package/core/entity/finders.ts +202 -0
- package/core/entity/pendingOps.ts +72 -0
- package/core/entity/saveEntity.ts +377 -0
- package/core/hooks/dispatcher.ts +439 -0
- package/core/hooks/guards.ts +155 -0
- package/core/hooks/registry.ts +247 -0
- package/core/metadata/definitions/Component.ts +1 -1
- package/core/metadata/index.ts +15 -4
- package/core/middleware/AccessLog.ts +8 -1
- package/core/middleware/RateLimit.ts +102 -105
- package/core/middleware/RequestId.ts +2 -9
- package/core/middleware/SecurityHeaders.ts +2 -11
- package/core/middleware/headers.ts +28 -0
- package/core/remote/OutboxWorker.ts +213 -183
- package/core/remote/RemoteManager.ts +401 -400
- package/core/remote/types.ts +153 -151
- package/core/requestScope.ts +34 -0
- package/core/scheduler/cronEvaluator.ts +174 -0
- package/core/scheduler/lifecycleHooks.ts +21 -0
- package/core/scheduler/lockCoordinator.ts +27 -0
- package/core/scheduler/metrics.ts +14 -0
- package/core/scheduler/taskRunner.ts +420 -0
- package/database/DatabaseHelper.ts +128 -101
- package/database/IndexingStrategy.ts +72 -2
- package/database/PreparedStatementCache.ts +20 -5
- package/database/cancellable.ts +35 -0
- package/database/index.ts +15 -3
- package/database/instrumentedDb.ts +141 -0
- package/endpoints/archetypes.ts +2 -8
- package/endpoints/tables.ts +6 -1
- package/gql/index.ts +1 -1
- package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
- package/package.json +22 -1
- package/query/CTENode.ts +5 -3
- package/query/ComponentInclusionNode.ts +240 -13
- package/query/OrNode.ts +6 -5
- package/query/Query.ts +203 -59
- package/query/QueryContext.ts +6 -0
- package/query/QueryDAG.ts +7 -2
- package/query/membershipSource.ts +66 -0
- package/storage/LocalStorageProvider.ts +8 -3
- package/studio/dist/assets/index-BMZ67Npg.js +254 -0
- package/studio/dist/assets/index-BpbuYz9g.css +1 -0
- package/studio/{index.html → dist/index.html} +3 -2
- package/swagger/generator.ts +11 -1
- package/upload/UploadManager.ts +8 -6
- package/utils/uuid.ts +40 -10
- package/.claude/settings.local.json +0 -47
- package/.prettierrc +0 -4
- package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
- package/.serena/memories/architecture.md +0 -154
- package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
- package/.serena/memories/code_style_and_conventions.md +0 -76
- package/.serena/memories/project_overview.md +0 -43
- package/.serena/memories/schema-dsl-plan.md +0 -107
- package/.serena/memories/suggested_commands.md +0 -80
- package/.serena/memories/typescript-compilation-status.md +0 -54
- package/.serena/project.yml +0 -114
- package/BunSane.jpg +0 -0
- package/CLAUDE.md +0 -198
- package/TODO.md +0 -2
- package/bun.lock +0 -302
- package/bunfig.toml +0 -10
- package/docs/SCALABILITY_PLAN.md +0 -175
- package/studio/bun.lock +0 -482
- package/studio/package.json +0 -39
- package/studio/postcss.config.js +0 -6
- package/studio/src/components/DataTable.tsx +0 -211
- package/studio/src/components/Layout.tsx +0 -13
- package/studio/src/components/PageContainer.tsx +0 -9
- package/studio/src/components/PageHeader.tsx +0 -13
- package/studio/src/components/SearchBar.tsx +0 -57
- package/studio/src/components/Sidebar.tsx +0 -294
- package/studio/src/components/ui/button.tsx +0 -56
- package/studio/src/components/ui/checkbox.tsx +0 -26
- package/studio/src/components/ui/input.tsx +0 -25
- package/studio/src/hooks/useDataTable.ts +0 -131
- package/studio/src/index.css +0 -36
- package/studio/src/lib/api.ts +0 -186
- package/studio/src/lib/utils.ts +0 -13
- package/studio/src/main.tsx +0 -17
- package/studio/src/pages/ArcheType.tsx +0 -239
- package/studio/src/pages/Components.tsx +0 -124
- package/studio/src/pages/EntityInspector.tsx +0 -302
- package/studio/src/pages/QueryRunner.tsx +0 -246
- package/studio/src/pages/Table.tsx +0 -94
- package/studio/src/pages/Welcome.tsx +0 -241
- package/studio/src/routes.tsx +0 -45
- package/studio/src/store/archeTypeSettings.ts +0 -30
- package/studio/src/store/studio.ts +0 -65
- package/studio/src/utils/columnHelpers.tsx +0 -114
- package/studio/studio-instructions.md +0 -81
- package/studio/tailwind.config.js +0 -77
- package/studio/utils.ts +0 -54
- package/studio/vite.config.js +0 -19
- package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
- package/tests/benchmark/bunfig.toml +0 -9
- package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
- package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
- package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
- package/tests/benchmark/fixtures/index.ts +0 -6
- package/tests/benchmark/index.ts +0 -22
- package/tests/benchmark/noop-preload.ts +0 -3
- package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
- package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
- package/tests/benchmark/runners/index.ts +0 -4
- package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
- package/tests/benchmark/scripts/generate-db.ts +0 -344
- package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
- package/tests/e2e/http.test.ts +0 -130
- package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
- package/tests/fixtures/components/TestOrder.ts +0 -23
- package/tests/fixtures/components/TestProduct.ts +0 -23
- package/tests/fixtures/components/TestUser.ts +0 -20
- package/tests/fixtures/components/index.ts +0 -6
- package/tests/graphql/SchemaGeneration.test.ts +0 -90
- package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
- package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
- package/tests/helpers/MockRedisClient.ts +0 -113
- package/tests/helpers/MockRedisStreamServer.ts +0 -448
- package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
- package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
- package/tests/integration/entity/Entity.persistence.test.ts +0 -333
- package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
- package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
- package/tests/integration/query/Query.edgeCases.test.ts +0 -595
- package/tests/integration/query/Query.exec.test.ts +0 -576
- package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
- package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
- package/tests/integration/remote/dlq.test.ts +0 -175
- package/tests/integration/remote/event-dispatch.test.ts +0 -114
- package/tests/integration/remote/outbox.test.ts +0 -130
- package/tests/integration/remote/rpc.test.ts +0 -177
- package/tests/pglite-setup.ts +0 -62
- package/tests/setup.ts +0 -164
- package/tests/stress/BenchmarkRunner.ts +0 -203
- package/tests/stress/DataSeeder.ts +0 -190
- package/tests/stress/StressTestReporter.ts +0 -229
- package/tests/stress/cursor-perf-test.ts +0 -171
- package/tests/stress/fixtures/RealisticComponents.ts +0 -235
- package/tests/stress/fixtures/StressTestComponents.ts +0 -58
- package/tests/stress/index.ts +0 -7
- package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
- package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
- package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
- package/tests/unit/BatchLoader.test.ts +0 -196
- package/tests/unit/archetype/ArcheType.test.ts +0 -107
- package/tests/unit/cache/CacheManager.test.ts +0 -367
- package/tests/unit/cache/MemoryCache.test.ts +0 -260
- package/tests/unit/cache/RedisCache.test.ts +0 -411
- package/tests/unit/entity/Entity.components.test.ts +0 -317
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
- package/tests/unit/entity/Entity.reload.test.ts +0 -63
- package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
- package/tests/unit/entity/Entity.test.ts +0 -345
- package/tests/unit/gql/depthLimit.test.ts +0 -203
- package/tests/unit/gql/operationMiddleware.test.ts +0 -293
- package/tests/unit/health/Health.test.ts +0 -129
- package/tests/unit/middleware/AccessLog.test.ts +0 -37
- package/tests/unit/middleware/Middleware.test.ts +0 -98
- package/tests/unit/middleware/RequestId.test.ts +0 -54
- package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
- package/tests/unit/query/FilterBuilder.test.ts +0 -111
- package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
- package/tests/unit/query/Query.emptyString.test.ts +0 -69
- package/tests/unit/query/Query.test.ts +0 -310
- package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
- package/tests/unit/remote/RemoteError.test.ts +0 -55
- package/tests/unit/remote/decorators.test.ts +0 -195
- package/tests/unit/remote/metrics.test.ts +0 -115
- package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
- package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
- package/tests/unit/schema/schema-integration.test.ts +0 -426
- package/tests/unit/schema/schema.test.ts +0 -580
- package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
- package/tests/unit/upload/RestUpload.test.ts +0 -267
- package/tests/unit/validateEnv.test.ts +0 -82
- package/tests/utils/entity-tracker.ts +0 -57
- package/tests/utils/index.ts +0 -13
- 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
|
}
|
package/core/metadata/index.ts
CHANGED
|
@@ -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
|
-
|
|
44
|
+
_metadataCache = { archeTypes };
|
|
45
|
+
return _metadataCache;
|
|
46
|
+
}
|
|
38
47
|
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
status
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
{
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
'
|
|
86
|
-
'
|
|
87
|
-
'X-RateLimit-
|
|
88
|
-
'X-RateLimit-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
}
|