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,233 @@
|
|
|
1
|
+
import { type CacheProvider } from '../CacheProvider';
|
|
2
|
+
import { type CacheConfig } from '../../../config/cache.config';
|
|
3
|
+
import { logger } from '../../Logger';
|
|
4
|
+
import type { Entity } from '../../Entity';
|
|
5
|
+
import type { BaseComponent } from '../../components';
|
|
6
|
+
import type { ComponentData } from '../../RequestLoaders';
|
|
7
|
+
|
|
8
|
+
// Must match the value exported by CacheManager — inlined here to avoid
|
|
9
|
+
// a circular import (CacheManager imports this module).
|
|
10
|
+
const COMPONENT_TOMBSTONE = '__TOMBSTONE__' as const;
|
|
11
|
+
type ComponentCacheValue = ComponentData | typeof COMPONENT_TOMBSTONE;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Write-through strategy: entity get/set operations
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export async function getEntity(provider: CacheProvider, config: CacheConfig, id: string): Promise<string | null> {
|
|
18
|
+
if (!config.enabled || !config.entity?.enabled) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const key = `entity:${id}`;
|
|
24
|
+
const result = await provider.get<string>(key);
|
|
25
|
+
return result || null;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting entity from cache', error });
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function setEntityWriteThrough(provider: CacheProvider, config: CacheConfig, entity: Entity, ttl?: number): Promise<void> {
|
|
33
|
+
if (!config.enabled || !config.entity?.enabled) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const key = `entity:${entity.id}`;
|
|
39
|
+
const effectiveTTL = ttl ?? config.entity.ttl;
|
|
40
|
+
// Only cache entity ID for existence check
|
|
41
|
+
await provider.set(key, entity.id, effectiveTTL);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting entity in cache', error });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function getEntities(provider: CacheProvider, config: CacheConfig, ids: string[]): Promise<(string | null)[]> {
|
|
48
|
+
if (!config.enabled || !config.entity?.enabled) {
|
|
49
|
+
return ids.map(() => null);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const cacheKeys = ids.map(id => `entity:${id}`);
|
|
54
|
+
const results = await provider.getMany<string>(cacheKeys);
|
|
55
|
+
return results;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting entities from cache', error });
|
|
58
|
+
return ids.map(() => null);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function setEntitiesWriteThrough(provider: CacheProvider, config: CacheConfig, entities: Entity[], ttl?: number): Promise<void> {
|
|
63
|
+
if (!config.enabled || !config.entity?.enabled) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const effectiveTTL = ttl ?? config.entity?.ttl;
|
|
69
|
+
const entries = entities.map(entity => ({
|
|
70
|
+
key: `entity:${entity.id}`,
|
|
71
|
+
// Only cache entity ID for existence check
|
|
72
|
+
value: entity.id,
|
|
73
|
+
ttl: effectiveTTL
|
|
74
|
+
}));
|
|
75
|
+
await provider.setMany(entries);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting entities in cache', error });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function getComponentsByEntity(provider: CacheProvider, config: CacheConfig, entityId: string, componentType?: string): Promise<BaseComponent[] | null> {
|
|
82
|
+
if (!config.enabled || !config.component?.enabled) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const key = componentType
|
|
88
|
+
? `component:${entityId}:${componentType}`
|
|
89
|
+
: `components:${entityId}`;
|
|
90
|
+
return await provider.get<BaseComponent[]>(key);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting components from cache', error });
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Set components for an entity in cache with write-through strategy.
|
|
99
|
+
* Converts BaseComponent instances to ComponentData format for cache compatibility with DataLoader.
|
|
100
|
+
* Delegates to setComponentsBatchWriteThrough for a single-entity, 2-RTT batch.
|
|
101
|
+
*/
|
|
102
|
+
export async function setComponentWriteThrough(provider: CacheProvider, config: CacheConfig, entityId: string, components: BaseComponent[], componentType?: string, ttl?: number): Promise<void> {
|
|
103
|
+
if (!config.enabled || !config.component?.enabled) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const entries = components.map(c => ({
|
|
107
|
+
entityId,
|
|
108
|
+
typeId: componentType || c.getTypeID(),
|
|
109
|
+
component: c,
|
|
110
|
+
ttl,
|
|
111
|
+
}));
|
|
112
|
+
await setComponentsBatchWriteThrough(provider, config, entries);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Batch write-through for BaseComponent instances across any number of
|
|
117
|
+
* entities. Performs exactly 2 Redis round-trips regardless of entry count:
|
|
118
|
+
* 1. pipelined getMany — reads existing entries to preserve createdAt (H-CACHE-3)
|
|
119
|
+
* 2. pipelined setMany — writes all updated entries
|
|
120
|
+
*/
|
|
121
|
+
export async function setComponentsBatchWriteThrough(
|
|
122
|
+
provider: CacheProvider,
|
|
123
|
+
config: CacheConfig,
|
|
124
|
+
entries: Array<{ entityId: string; typeId: string; component: BaseComponent; ttl?: number }>,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
if (!config.enabled || !config.component?.enabled || entries.length === 0) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const effectiveTTL = config.component.ttl;
|
|
132
|
+
const keys = entries.map(e => `component:${e.entityId}:${e.typeId}`);
|
|
133
|
+
|
|
134
|
+
// One batched read — preserves createdAt from existing entries (H-CACHE-3).
|
|
135
|
+
const existing = await provider.getMany<ComponentData>(keys);
|
|
136
|
+
|
|
137
|
+
const now = new Date();
|
|
138
|
+
const setEntries = entries.map((e, i) => {
|
|
139
|
+
const prev = existing[i];
|
|
140
|
+
const createdAt: Date =
|
|
141
|
+
prev && prev.createdAt
|
|
142
|
+
? (prev.createdAt instanceof Date ? prev.createdAt : new Date(prev.createdAt))
|
|
143
|
+
: now;
|
|
144
|
+
|
|
145
|
+
const componentData: ComponentData = {
|
|
146
|
+
id: e.component.id,
|
|
147
|
+
entityId: e.entityId,
|
|
148
|
+
typeId: e.typeId,
|
|
149
|
+
data: e.component.data(),
|
|
150
|
+
createdAt,
|
|
151
|
+
updatedAt: now,
|
|
152
|
+
deletedAt: null,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return { key: keys[i]!, value: componentData, ttl: e.ttl ?? effectiveTTL };
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// One batched write.
|
|
159
|
+
await provider.setMany(setEntries);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting components in cache (batch)', err: error });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function getComponents(provider: CacheProvider, config: CacheConfig, keys: Array<{ entityId: string; typeId: string }>): Promise<(ComponentCacheValue | null)[]> {
|
|
166
|
+
if (!config.enabled || !config.component?.enabled) {
|
|
167
|
+
return keys.map(() => null);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const cacheKeys = keys.map(k => `component:${k.entityId}:${k.typeId}`);
|
|
172
|
+
const results = await provider.getMany<ComponentCacheValue>(cacheKeys);
|
|
173
|
+
return results;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting components from cache', error });
|
|
176
|
+
return keys.map(() => null);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Set components in cache with write-through strategy (for DataLoader integration).
|
|
182
|
+
*
|
|
183
|
+
* When `requestedKeys` is supplied and `component.negativeCacheEnabled` is
|
|
184
|
+
* true, tombstones are written for any requested key not present in
|
|
185
|
+
* `components` (within the same setMany call — single round-trip).
|
|
186
|
+
*/
|
|
187
|
+
export async function setComponentsWriteThrough(
|
|
188
|
+
provider: CacheProvider,
|
|
189
|
+
config: CacheConfig,
|
|
190
|
+
components: ComponentData[],
|
|
191
|
+
ttlOrRequested?: number | Array<{ entityId: string; typeId: string }>,
|
|
192
|
+
ttlIfRequested?: number,
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
if (!config.enabled || !config.component?.enabled) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Backward-compatible overload: (components, ttl?) or (components, requestedKeys, ttl?)
|
|
199
|
+
const requestedKeys = Array.isArray(ttlOrRequested) ? ttlOrRequested : undefined;
|
|
200
|
+
const ttl = Array.isArray(ttlOrRequested) ? ttlIfRequested : ttlOrRequested;
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const componentTTL = ttl ?? config.component.ttl;
|
|
204
|
+
const entries: Array<{ key: string; value: ComponentCacheValue; ttl: number }> = components.map(comp => ({
|
|
205
|
+
key: `component:${comp.entityId}:${comp.typeId}`,
|
|
206
|
+
value: comp,
|
|
207
|
+
ttl: componentTTL,
|
|
208
|
+
}));
|
|
209
|
+
|
|
210
|
+
const negativeEnabled = config.component.negativeCacheEnabled === true;
|
|
211
|
+
if (negativeEnabled && requestedKeys && requestedKeys.length > 0) {
|
|
212
|
+
const found = new Set(components.map(c => `${c.entityId}-${c.typeId}`));
|
|
213
|
+
const tombstoneTTL = config.component.negativeCacheTtl
|
|
214
|
+
?? Math.min(componentTTL, 60_000);
|
|
215
|
+
for (const k of requestedKeys) {
|
|
216
|
+
const dedupeKey = `${k.entityId}-${k.typeId}`;
|
|
217
|
+
if (!found.has(dedupeKey)) {
|
|
218
|
+
entries.push({
|
|
219
|
+
key: `component:${k.entityId}:${k.typeId}`,
|
|
220
|
+
value: COMPONENT_TOMBSTONE,
|
|
221
|
+
ttl: tombstoneTTL,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (entries.length > 0) {
|
|
228
|
+
await provider.setMany(entries);
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting components in cache', error });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -52,11 +52,16 @@ export class BaseComponent {
|
|
|
52
52
|
const data: Record<string, any> = {};
|
|
53
53
|
const storage = getMetadataStorage();
|
|
54
54
|
const props = storage.componentProperties.get(this._typeId);
|
|
55
|
-
|
|
55
|
+
if (!props) return data;
|
|
56
|
+
// Iterate the property metadata directly — avoids the prior O(n²)
|
|
57
|
+
// pattern (properties().forEach + props.find per property) and the
|
|
58
|
+
// redundant second metadata lookup inside properties(). Hot write path:
|
|
59
|
+
// runs for every dirty component on every save.
|
|
60
|
+
for (const propMeta of props) {
|
|
61
|
+
const prop = propMeta.propertyKey;
|
|
56
62
|
let value = (this as any)[prop];
|
|
57
|
-
const propMeta = props?.find(p => p.propertyKey === prop);
|
|
58
63
|
if (value !== null && value !== undefined) {
|
|
59
|
-
if (propMeta
|
|
64
|
+
if (propMeta.propertyType === Date) {
|
|
60
65
|
if (!(value instanceof Date)) {
|
|
61
66
|
throw new Error(`Type mismatch for property '${prop}' on component '${this._comp_name}': expected Date, got ${typeof value}`);
|
|
62
67
|
}
|
|
@@ -64,17 +69,21 @@ export class BaseComponent {
|
|
|
64
69
|
throw new Error(`Invalid Date for property '${prop}' on component '${this._comp_name}'`);
|
|
65
70
|
}
|
|
66
71
|
value = value.toISOString();
|
|
67
|
-
} else if (propMeta
|
|
72
|
+
} else if (propMeta.propertyType === Number && typeof value === 'number' && !Number.isFinite(value)) {
|
|
68
73
|
throw new Error(`Invalid number for property '${prop}' on component '${this._comp_name}': ${value}`);
|
|
69
74
|
}
|
|
70
75
|
}
|
|
71
76
|
data[prop] = value;
|
|
72
|
-
}
|
|
77
|
+
}
|
|
73
78
|
return data;
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
async save(trx: Bun.SQL, entity_id: string) {
|
|
77
|
-
|
|
82
|
+
// Level-gated: template literal allocates per component save even
|
|
83
|
+
// when trace is disabled.
|
|
84
|
+
if (logger.isLevelEnabled?.('trace')) {
|
|
85
|
+
logger.trace(`Saving component ${this._comp_name} for entity ${entity_id}`);
|
|
86
|
+
}
|
|
78
87
|
// Only check readiness if component is not yet registered
|
|
79
88
|
// This optimization avoids 40,000+ unnecessary async calls for bulk operations
|
|
80
89
|
if(!ComponentRegistry.isComponentReady(this._comp_name)) {
|
|
@@ -98,10 +107,9 @@ export class BaseComponent {
|
|
|
98
107
|
if (!entity_id || entity_id.trim() === '') {
|
|
99
108
|
throw new Error(`Cannot insert component ${this._comp_name}: entity_id is empty or invalid`);
|
|
100
109
|
}
|
|
101
|
-
await trx`INSERT INTO components
|
|
110
|
+
await trx`INSERT INTO components
|
|
102
111
|
(id, entity_id, name, type_id, data)
|
|
103
112
|
VALUES (${this.id}, ${entity_id}, ${this._comp_name}, ${this._typeId}, ${this.serializableData()})`
|
|
104
|
-
await trx`INSERT INTO entity_components (entity_id, type_id, component_id) VALUES (${entity_id}, ${this._typeId}, ${this.id}) ON CONFLICT DO NOTHING`
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
async update(trx: Bun.SQL) {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
GenerateTableName,
|
|
9
9
|
UpdateComponentIndexes,
|
|
10
10
|
AnalyzeAllComponentTables,
|
|
11
|
+
CreateRelationIndexes,
|
|
11
12
|
GetPartitionStrategy,
|
|
12
13
|
} from "../../database/DatabaseHelper";
|
|
13
14
|
import { ensureMultipleJSONBPathIndexes } from "../../database/IndexingStrategy";
|
|
@@ -34,6 +35,7 @@ class ComponentRegistry {
|
|
|
34
35
|
private readinessPromises = new Map<string, Promise<void>>();
|
|
35
36
|
private readinessResolvers = new Map<string, () => void>();
|
|
36
37
|
private componentsRegistered: boolean = false;
|
|
38
|
+
private cachedPartitionStrategy: string | null = null;
|
|
37
39
|
|
|
38
40
|
constructor() {}
|
|
39
41
|
|
|
@@ -200,6 +202,18 @@ class ComponentRegistry {
|
|
|
200
202
|
}
|
|
201
203
|
|
|
202
204
|
register(name: string, typeid: string, ctor: ComponentConstructor) {
|
|
205
|
+
// Warn when a LIST partition is being attached after startup registration
|
|
206
|
+
// completed. CREATE TABLE ... PARTITION OF takes ACCESS EXCLUSIVE on the
|
|
207
|
+
// parent `components` table, stalling all component I/O during the lock.
|
|
208
|
+
// Fine at boot; dangerous if a request triggers first use of a new type.
|
|
209
|
+
if (this.componentsRegistered && this.cachedPartitionStrategy === 'list') {
|
|
210
|
+
logger.warn(
|
|
211
|
+
`Runtime partition attach for component "${name}" takes ACCESS EXCLUSIVE on the ` +
|
|
212
|
+
`components table, stalling all component reads and writes until the DDL completes. ` +
|
|
213
|
+
`Pre-register all components at startup, or set BUNSANE_PARTITION_STRATEGY=hash ` +
|
|
214
|
+
`to avoid per-component partitions.`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
203
217
|
return new Promise<boolean>(async (resolve) => {
|
|
204
218
|
const partitionTableName = GenerateTableName(name);
|
|
205
219
|
// await this.populateCurrentTables();
|
|
@@ -289,6 +303,7 @@ class ComponentRegistry {
|
|
|
289
303
|
|
|
290
304
|
// Check partitioning strategy for index creation
|
|
291
305
|
const partitionStrategy = await GetPartitionStrategy();
|
|
306
|
+
this.cachedPartitionStrategy = partitionStrategy;
|
|
292
307
|
|
|
293
308
|
// Update component indexes for components that have indexed properties
|
|
294
309
|
// NOTE: Index operations are serialized to prevent deadlocks with ANALYZE
|
|
@@ -346,6 +361,19 @@ class ComponentRegistry {
|
|
|
346
361
|
}
|
|
347
362
|
logger.info(`Registered hooks for ${services.length} services`);
|
|
348
363
|
|
|
364
|
+
// Create btree indexes on archetype relation foreign-key fields
|
|
365
|
+
// (data->>'fk'). Without these, @BelongsTo/@HasMany resolver queries
|
|
366
|
+
// sequentially scan the relation component partition tables. Runs
|
|
367
|
+
// before ANALYZE so the planner picks up fresh stats for the new
|
|
368
|
+
// indexes. Idempotent (IF NOT EXISTS) and CONCURRENTLY on LIST
|
|
369
|
+
// partitions, so it is safe to re-run on every startup against live
|
|
370
|
+
// tables.
|
|
371
|
+
try {
|
|
372
|
+
await CreateRelationIndexes();
|
|
373
|
+
} catch (error) {
|
|
374
|
+
logger.warn(`Failed to create relation FK indexes: ${error}`);
|
|
375
|
+
}
|
|
376
|
+
|
|
349
377
|
// Run ANALYZE on all component tables to update query planner statistics
|
|
350
378
|
await AnalyzeAllComponentTables();
|
|
351
379
|
}
|
|
@@ -11,7 +11,7 @@ import { getMetadataStorage } from '../metadata';
|
|
|
11
11
|
* - 'numeric': BTREE index with numeric cast for range queries (>, <, BETWEEN)
|
|
12
12
|
* @param isDateField Whether this field contains date values (affects BTREE index casting)
|
|
13
13
|
*/
|
|
14
|
-
export function IndexedField(indexType: 'gin' | 'btree' | 'hash' | 'numeric' = 'gin', isDateField: boolean = false) {
|
|
14
|
+
export function IndexedField(indexType: 'gin' | 'btree' | 'hash' | 'numeric' | 'fulltext' = 'gin', isDateField: boolean = false) {
|
|
15
15
|
return function(target: any, propertyKey: string) {
|
|
16
16
|
const storage = getMetadataStorage();
|
|
17
17
|
const componentId = storage.getComponentId(target.constructor.name);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Cache side-effect strategies for Entity save/delete. Extracted from
|
|
2
|
+
// Entity.ts (RFC_REFACTOR_TARGETS §3.2). Pure functions take the entity
|
|
3
|
+
// instance as the first parameter.
|
|
4
|
+
import type { BaseComponent } from "../components";
|
|
5
|
+
import { logger } from "../Logger";
|
|
6
|
+
import EntityHookManager from "../EntityHookManager";
|
|
7
|
+
import { EntityDeletedEvent } from "../events/EntityLifecycleEvents";
|
|
8
|
+
import type { SQL } from "bun";
|
|
9
|
+
import type { Entity } from "../Entity";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Handle cache operations after successful save
|
|
13
|
+
* @param changedComponentTypeIds - Component type IDs that were dirty before save (captured before doSave clears flags)
|
|
14
|
+
* @param removedComponentTypeIds - Component type IDs that were removed (captured before doSave clears the set)
|
|
15
|
+
*/
|
|
16
|
+
export async function handleCacheAfterSave(entity: Entity, changedComponentTypeIds: string[], removedComponentTypeIds: string[], context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): Promise<void> {
|
|
17
|
+
try {
|
|
18
|
+
// Import CacheManager dynamically to avoid circular dependency
|
|
19
|
+
const { CacheManager } = await import('../cache/CacheManager');
|
|
20
|
+
const cacheManager = CacheManager.getInstance();
|
|
21
|
+
const config = cacheManager.getConfig();
|
|
22
|
+
|
|
23
|
+
const entityEnabled = !!(config.enabled && config.entity?.enabled);
|
|
24
|
+
const componentEnabled = !!(config.enabled && config.component?.enabled);
|
|
25
|
+
|
|
26
|
+
if (entityEnabled && config.strategy === 'write-through') {
|
|
27
|
+
await cacheManager.setEntityWriteThrough(entity, config.entity!.ttl);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Handle component cache invalidation with granular approach
|
|
31
|
+
if (componentEnabled) {
|
|
32
|
+
// Use the pre-captured lists instead of re-querying (dirty flags are already cleared by doSave)
|
|
33
|
+
|
|
34
|
+
if (config.strategy === 'write-through') {
|
|
35
|
+
// Single batched write-through (2 pipelined provider
|
|
36
|
+
// round-trips total) instead of one GET+SET pair per
|
|
37
|
+
// changed component.
|
|
38
|
+
const entries = changedComponentTypeIds
|
|
39
|
+
.map(typeId => ({ typeId, component: entity.components.get(typeId) }))
|
|
40
|
+
.filter((e): e is { typeId: string; component: BaseComponent } => !!e.component)
|
|
41
|
+
.map(e => ({ entityId: entity.id, typeId: e.typeId, component: e.component, ttl: config.component!.ttl }));
|
|
42
|
+
if (entries.length > 0) {
|
|
43
|
+
await cacheManager.setComponentsBatchWriteThrough(entries);
|
|
44
|
+
}
|
|
45
|
+
// Removed components must still drop out of cache.
|
|
46
|
+
if (removedComponentTypeIds.length > 0) {
|
|
47
|
+
await cacheManager.invalidateEntityComponents(entity.id, removedComponentTypeIds);
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
// One deleteMany + ONE pub/sub message for the whole save
|
|
51
|
+
// (entity key included) — previously N+1 DEL+PUBLISH pairs
|
|
52
|
+
// per save, fanning out to every other instance.
|
|
53
|
+
const toInvalidate = [...changedComponentTypeIds, ...removedComponentTypeIds];
|
|
54
|
+
if (toInvalidate.length > 0 || entityEnabled) {
|
|
55
|
+
await cacheManager.invalidateEntityComponents(entity.id, toInvalidate, { includeEntityKey: entityEnabled });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Invalidate DataLoader cache for changed + removed components
|
|
60
|
+
if (context?.loaders?.componentsByEntityType) {
|
|
61
|
+
for (const typeId of [...changedComponentTypeIds, ...removedComponentTypeIds]) {
|
|
62
|
+
context.loaders.componentsByEntityType.clear({
|
|
63
|
+
entityId: entity.id,
|
|
64
|
+
typeId: typeId
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} else if (entityEnabled && config.strategy !== 'write-through') {
|
|
69
|
+
await cacheManager.invalidateEntity(entity.id);
|
|
70
|
+
}
|
|
71
|
+
} catch (error) {
|
|
72
|
+
logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache operation failed after save', error });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function runPostDeleteSideEffects(entity: Entity, softDelete: boolean): Promise<void> {
|
|
77
|
+
try {
|
|
78
|
+
await EntityHookManager.executeHooks(new EntityDeletedEvent(entity, softDelete));
|
|
79
|
+
} catch (err) {
|
|
80
|
+
logger.error({ scope: 'hooks', entityId: entity.id, err }, 'post-delete lifecycle hooks failed');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const { CacheManager } = await import('../cache/CacheManager');
|
|
85
|
+
const cacheManager = CacheManager.getInstance();
|
|
86
|
+
const config = cacheManager.getConfig();
|
|
87
|
+
|
|
88
|
+
if (config.enabled && config.entity?.enabled) {
|
|
89
|
+
await cacheManager.invalidateEntity(entity.id);
|
|
90
|
+
}
|
|
91
|
+
if (config.enabled && config.component?.enabled) {
|
|
92
|
+
await cacheManager.invalidateAllEntityComponents(entity.id);
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
logger.warn({ scope: 'cache', entityId: entity.id, err }, 'post-delete cache invalidation failed');
|
|
96
|
+
}
|
|
97
|
+
}
|