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.
- package/CHANGELOG.md +471 -370
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +93 -1020
- package/core/EntityHookManager.ts +52 -754
- package/core/Logger.ts +10 -0
- package/core/RequestContext.ts +94 -85
- package/core/RequestLoaders.ts +98 -5
- package/core/SchedulerManager.ts +28 -600
- package/core/app/cors.ts +2 -11
- package/core/app/preparedStatementWarmup.ts +9 -49
- package/core/app/requestRouter.ts +9 -8
- package/core/app/restRegistry.ts +8 -0
- package/core/archetype/fieldResolvers.ts +85 -40
- package/core/archetype/relationLoader.ts +135 -92
- package/core/cache/CacheManager.ts +91 -302
- package/core/cache/CompressionUtils.ts +34 -3
- package/core/cache/MemoryCache.ts +40 -37
- package/core/cache/RedisCache.ts +8 -7
- 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 +25 -10
- 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 +383 -0
- package/core/entity/finders.ts +202 -0
- package/core/entity/getCacheManager.ts +10 -0
- package/core/entity/pendingOps.ts +72 -0
- package/core/entity/saveEntity.ts +375 -0
- package/core/health.ts +93 -4
- 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/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/StreamConsumer.ts +535 -535
- 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/core/validateEnv.ts +10 -0
- package/database/DatabaseHelper.ts +128 -101
- package/database/IndexingStrategy.ts +72 -2
- package/database/PreparedStatementCache.ts +8 -2
- package/database/cancellable.ts +35 -22
- package/database/index.ts +29 -3
- package/database/instrumentedDb.ts +141 -141
- package/database/sqlHelpers.ts +3 -1
- package/endpoints/archetypes.ts +2 -8
- package/endpoints/tables.ts +6 -1
- package/gql/index.ts +1 -1
- package/gql/schema/index.ts +15 -4
- package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
- package/package.json +22 -1
- package/query/CTENode.ts +5 -3
- package/query/ComponentInclusionNode.ts +245 -14
- package/query/OrNode.ts +8 -19
- package/query/Query.ts +208 -79
- 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/scheduled_tasks.lock +0 -1
- 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/RFC_APP_REFACTOR.md +0 -248
- package/docs/RFC_REFACTOR_TARGETS.md +0 -251
- 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/loaders/RequestLoaders.abort.test.ts +0 -82
- package/tests/integration/query/Query.abort.test.ts +0 -66
- 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 -498
- package/tests/unit/cache/MemoryCache.test.ts +0 -260
- package/tests/unit/cache/RedisCache.test.ts +0 -411
- package/tests/unit/database/cancellable.test.ts +0 -81
- package/tests/unit/database/instrumentedDb.test.ts +0 -160
- 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,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
|
}
|
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,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
|
+
}
|