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,364 @@
|
|
|
1
|
+
// Component access + mutation for Entity (add/set/remove/get/has/reload
|
|
2
|
+
// and the in-memory helpers). Extracted from Entity.ts
|
|
3
|
+
// (RFC_REFACTOR_TARGETS §3.2). Pure functions take the entity instance as
|
|
4
|
+
// the first parameter; hook phases/order are byte-identical to the
|
|
5
|
+
// original inline implementation.
|
|
6
|
+
import type { ComponentDataType, ComponentGetter, BaseComponent } from "../components";
|
|
7
|
+
import { logger } from "../Logger";
|
|
8
|
+
import db from "../../database";
|
|
9
|
+
import { runWithSignal } from "../../database/cancellable";
|
|
10
|
+
import ComponentRegistry from "../components/ComponentRegistry";
|
|
11
|
+
import { SQL } from "bun";
|
|
12
|
+
import EntityHookManager from "../EntityHookManager";
|
|
13
|
+
import { getMetadataStorage } from "../metadata";
|
|
14
|
+
import { ComponentAddedEvent, ComponentUpdatedEvent, ComponentRemovedEvent } from "../events/EntityLifecycleEvents";
|
|
15
|
+
import { getRequestScope } from "../requestScope";
|
|
16
|
+
import { trackCacheOp } from "./pendingOps";
|
|
17
|
+
import type { Entity } from "../Entity";
|
|
18
|
+
|
|
19
|
+
export function addComponent(entity: Entity, component: BaseComponent): Entity {
|
|
20
|
+
entity.components.set(component.getTypeID(), component);
|
|
21
|
+
return entity;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a component constructor to its type id. `getComponentId` is
|
|
26
|
+
* memoized in metadata storage, so this is an O(1) Map lookup with no
|
|
27
|
+
* component instantiation — unlike `new ctor().getTypeID()`. The
|
|
28
|
+
* `components` map is keyed by type id (see addComponent), so callers can
|
|
29
|
+
* then do `entity.components.get(typeId)` instead of allocating an array and
|
|
30
|
+
* scanning it with `instanceof`.
|
|
31
|
+
*/
|
|
32
|
+
export function typeIdOf(ctor: new (...args: any[]) => BaseComponent): string {
|
|
33
|
+
return getMetadataStorage().getComponentId(ctor.name);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function componentList(entity: Entity): BaseComponent[] {
|
|
37
|
+
return Array.from(entity.components.values());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getInMemory<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): T | undefined {
|
|
41
|
+
return entity.components.get(typeIdOf(ctor)) as T | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function hasInMemory<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): boolean {
|
|
45
|
+
return entity.components.has(typeIdOf(ctor));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function wasRemoved<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): boolean {
|
|
49
|
+
const typeId = typeIdOf(ctor);
|
|
50
|
+
// Check both pending removals and already-saved removals
|
|
51
|
+
return entity.removedComponents.has(typeId) || entity.savedRemovedComponents.has(typeId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function add<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, data?: Partial<ComponentDataType<T>>): Entity {
|
|
55
|
+
const instance = new ctor();
|
|
56
|
+
if (data) {
|
|
57
|
+
Object.assign(instance, data);
|
|
58
|
+
} else {
|
|
59
|
+
Object.assign(instance, {});
|
|
60
|
+
}
|
|
61
|
+
addComponent(entity, instance);
|
|
62
|
+
entity.setDirty(true);
|
|
63
|
+
// executeHooks is async; the surrounding try/catch only captures
|
|
64
|
+
// synchronous throws. Attach a .catch so an async rejection from a
|
|
65
|
+
// hook handler does not escape as an unhandled rejection (H-HOOK-1).
|
|
66
|
+
// Add stays sync to preserve the fluent chaining signature; hook
|
|
67
|
+
// failures are logged and do not fail the add operation.
|
|
68
|
+
Promise.resolve()
|
|
69
|
+
.then(() => EntityHookManager.executeHooks(new ComponentAddedEvent(entity, instance)))
|
|
70
|
+
.catch((error) => {
|
|
71
|
+
logger.error(`Error firing component added hook for ${instance.getTypeID()}: ${error}`);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return entity;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function set<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, data: Partial<ComponentDataType<T>>, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): Promise<Entity> {
|
|
78
|
+
await get(entity, ctor, context);
|
|
79
|
+
|
|
80
|
+
const component = entity.components.get(typeIdOf(ctor)) as T;
|
|
81
|
+
if (component) {
|
|
82
|
+
// Store old data for the update event
|
|
83
|
+
const oldData = { ...component };
|
|
84
|
+
|
|
85
|
+
// Update existing component
|
|
86
|
+
Object.assign(component, data);
|
|
87
|
+
component.setDirty(true);
|
|
88
|
+
entity.setDirty(true);
|
|
89
|
+
|
|
90
|
+
// Fire component updated event. Await so a hook rejection is
|
|
91
|
+
// captured by this method's try/catch and does not escape as an
|
|
92
|
+
// unhandled rejection (H-HOOK-1).
|
|
93
|
+
try {
|
|
94
|
+
await EntityHookManager.executeHooks(new ComponentUpdatedEvent(entity, component, oldData, component));
|
|
95
|
+
} catch (error) {
|
|
96
|
+
logger.error(`Error firing component updated hook for ${component.getTypeID()}: ${error}`);
|
|
97
|
+
// Don't fail the set operation if hooks fail
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Invalidate DataLoader cache if context is provided
|
|
101
|
+
if (context?.loaders?.componentsByEntityType) {
|
|
102
|
+
context.loaders.componentsByEntityType.clear({
|
|
103
|
+
entityId: entity.id,
|
|
104
|
+
typeId: component.getTypeID()
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Fire-and-forget cache update, tracked via drainable set so
|
|
109
|
+
// App.shutdown can await it (H-CACHE-1).
|
|
110
|
+
trackCacheOp((async () => {
|
|
111
|
+
try {
|
|
112
|
+
const { CacheManager } = await import('../cache/CacheManager');
|
|
113
|
+
const cacheManager = CacheManager.getInstance();
|
|
114
|
+
const config = cacheManager.getConfig();
|
|
115
|
+
|
|
116
|
+
if (config.enabled && config.component?.enabled) {
|
|
117
|
+
if (config.strategy === 'write-through') {
|
|
118
|
+
await cacheManager.setComponentWriteThrough(entity.id, [component], component.getTypeID(), config.component.ttl);
|
|
119
|
+
} else {
|
|
120
|
+
await cacheManager.invalidateComponent(entity.id, component.getTypeID());
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache operation failed after set', err: error });
|
|
125
|
+
}
|
|
126
|
+
})());
|
|
127
|
+
} else {
|
|
128
|
+
// Add new component
|
|
129
|
+
add(entity, ctor, data);
|
|
130
|
+
entity.setDirty(true);
|
|
131
|
+
// Note: add() already fires ComponentAddedEvent, so we don't need to fire it again
|
|
132
|
+
}
|
|
133
|
+
return entity;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function remove<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): boolean {
|
|
137
|
+
const component = entity.components.get(typeIdOf(ctor)) as T;
|
|
138
|
+
|
|
139
|
+
if (component) {
|
|
140
|
+
const typeId = component.getTypeID();
|
|
141
|
+
|
|
142
|
+
// Track the component type for database deletion
|
|
143
|
+
entity.removedComponents.add(typeId);
|
|
144
|
+
|
|
145
|
+
// Remove the component from the map
|
|
146
|
+
entity.components.delete(typeId);
|
|
147
|
+
entity.setDirty(true);
|
|
148
|
+
|
|
149
|
+
// Fire component removed event. remove() stays sync to preserve
|
|
150
|
+
// the boolean return signature used by callers; attach .catch so
|
|
151
|
+
// async hook rejections do not escape (H-HOOK-1).
|
|
152
|
+
Promise.resolve()
|
|
153
|
+
.then(() => EntityHookManager.executeHooks(new ComponentRemovedEvent(entity, component)))
|
|
154
|
+
.catch((error) => {
|
|
155
|
+
logger.error(`Error firing component removed hook for ${typeId}: ${error}`);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Invalidate DataLoader cache if context is provided
|
|
159
|
+
if (context?.loaders?.componentsByEntityType) {
|
|
160
|
+
context.loaders.componentsByEntityType.clear({
|
|
161
|
+
entityId: entity.id,
|
|
162
|
+
typeId: typeId
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Fire-and-forget cache invalidation, tracked for shutdown drain
|
|
167
|
+
// (H-CACHE-1).
|
|
168
|
+
trackCacheOp((async () => {
|
|
169
|
+
try {
|
|
170
|
+
const { CacheManager } = await import('../cache/CacheManager');
|
|
171
|
+
const cacheManager = CacheManager.getInstance();
|
|
172
|
+
const config = cacheManager.getConfig();
|
|
173
|
+
|
|
174
|
+
if (config.enabled && config.component?.enabled) {
|
|
175
|
+
await cacheManager.invalidateComponent(entity.id, typeId);
|
|
176
|
+
}
|
|
177
|
+
} catch (error) {
|
|
178
|
+
logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache invalidation failed after remove', err: error });
|
|
179
|
+
}
|
|
180
|
+
})());
|
|
181
|
+
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function get<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): Promise<ComponentDataType<T> | null> {
|
|
189
|
+
const comp = await loadComponent(entity, ctor, context);
|
|
190
|
+
return comp ? (comp as ComponentGetter<T>).data() : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function has<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): boolean {
|
|
194
|
+
return hasInMemory(entity, ctor);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function getOrThrow<T extends BaseComponent>(
|
|
198
|
+
entity: Entity,
|
|
199
|
+
ctor: new (...args: any[]) => T,
|
|
200
|
+
context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }
|
|
201
|
+
): Promise<ComponentDataType<T>> {
|
|
202
|
+
const data = await get(entity, ctor, context);
|
|
203
|
+
if (data === null) {
|
|
204
|
+
throw new Error(`Entity ${entity.id} is missing required component ${ctor.name}`);
|
|
205
|
+
}
|
|
206
|
+
return data;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function getCached<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T): ComponentDataType<T> | undefined {
|
|
210
|
+
const comp = getInMemory(entity, ctor);
|
|
211
|
+
return comp ? (comp as ComponentGetter<T>).data() : undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function getInstanceOf<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): Promise<T | null> {
|
|
215
|
+
return loadComponent(entity, ctor, context);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function reload(entity: Entity, opts?: { trx?: SQL; signal?: AbortSignal }): Promise<Entity> {
|
|
219
|
+
if (!entity.id || entity.id.trim() === '') {
|
|
220
|
+
return entity;
|
|
221
|
+
}
|
|
222
|
+
entity.components.clear();
|
|
223
|
+
entity.removedComponents.clear();
|
|
224
|
+
entity.savedRemovedComponents.clear();
|
|
225
|
+
|
|
226
|
+
const dbConn = opts?.trx ?? db;
|
|
227
|
+
const rows = await runWithSignal<any[]>(
|
|
228
|
+
dbConn`
|
|
229
|
+
SELECT c.id, c.type_id, c.data
|
|
230
|
+
FROM components c
|
|
231
|
+
WHERE c.entity_id = ${entity.id} AND c.deleted_at IS NULL
|
|
232
|
+
`,
|
|
233
|
+
opts?.signal
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const storage = getMetadataStorage();
|
|
237
|
+
for (const row of rows) {
|
|
238
|
+
const ctor = ComponentRegistry.getConstructor(row.type_id);
|
|
239
|
+
if (!ctor) continue;
|
|
240
|
+
const comp: any = new ctor();
|
|
241
|
+
const parsed = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
|
|
242
|
+
Object.assign(comp, parsed);
|
|
243
|
+
comp.id = row.id;
|
|
244
|
+
const props = storage.componentProperties.get(row.type_id);
|
|
245
|
+
if (props) {
|
|
246
|
+
for (const prop of props) {
|
|
247
|
+
if (prop.propertyType === Date && typeof comp[prop.propertyKey] === 'string') {
|
|
248
|
+
comp[prop.propertyKey] = new Date(comp[prop.propertyKey]);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
comp.setPersisted(true);
|
|
253
|
+
comp.setDirty(false);
|
|
254
|
+
addComponent(entity, comp);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
entity.setPersisted(true);
|
|
258
|
+
entity.setDirty(false);
|
|
259
|
+
return entity;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function requireComponents(entity: Entity, ctors: Array<new (...args: any[]) => BaseComponent>): Promise<void> {
|
|
263
|
+
if (ctors.length === 0) return;
|
|
264
|
+
const missing: string[] = [];
|
|
265
|
+
for (const ctor of ctors) {
|
|
266
|
+
// components is keyed by type id — O(1) lookup, no instantiation
|
|
267
|
+
// and no O(K) instanceof scan per constructor.
|
|
268
|
+
const typeId = typeIdOf(ctor);
|
|
269
|
+
if (!entity.components.has(typeId)) {
|
|
270
|
+
missing.push(typeId);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (missing.length === 0) return;
|
|
274
|
+
const { Entity } = await import("../Entity");
|
|
275
|
+
await Entity.LoadComponents([entity], missing);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function loadComponent<T extends BaseComponent>(entity: Entity, ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): Promise<T | null> {
|
|
279
|
+
const comp = entity.components.get(typeIdOf(ctor)) as T | undefined;
|
|
280
|
+
if (typeof comp !== "undefined") {
|
|
281
|
+
return comp;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Validate entity ID before database query
|
|
285
|
+
if (!entity.id || entity.id.trim() === '') {
|
|
286
|
+
logger.warn(`Cannot load component ${ctor.name}: entity id is empty`);
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Memoized metadata lookup — no throwaway component instantiation
|
|
291
|
+
// just to read the type id.
|
|
292
|
+
const typeId = typeIdOf(ctor);
|
|
293
|
+
|
|
294
|
+
// Use transaction if provided, otherwise use default db
|
|
295
|
+
const dbConn = context?.trx ?? db;
|
|
296
|
+
|
|
297
|
+
// Ambient request scope fallback: bare entity.get() calls (e.g.
|
|
298
|
+
// inside @ArcheTypeFunction bodies or Unwrap()) batch through the
|
|
299
|
+
// request's DataLoaders instead of firing one SELECT per call.
|
|
300
|
+
// Never substituted when the caller passed an explicit trx — a
|
|
301
|
+
// loader read outside the transaction could see stale data.
|
|
302
|
+
const scope = (!context?.loaders && !context?.trx) ? getRequestScope() : undefined;
|
|
303
|
+
const loaders = context?.loaders ?? scope?.loaders;
|
|
304
|
+
const signal = context?.signal ?? scope?.signal;
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
let componentData: any = null;
|
|
308
|
+
let componentId: string | null = null;
|
|
309
|
+
|
|
310
|
+
if (loaders?.componentsByEntityType) {
|
|
311
|
+
const loaderResult = await loaders.componentsByEntityType.load({
|
|
312
|
+
entityId: entity.id,
|
|
313
|
+
typeId: typeId
|
|
314
|
+
});
|
|
315
|
+
if (loaderResult) {
|
|
316
|
+
componentData = loaderResult.data;
|
|
317
|
+
componentId = loaderResult.id;
|
|
318
|
+
}
|
|
319
|
+
} else {
|
|
320
|
+
// Route through runWithSignal so a request/wall-clock abort can
|
|
321
|
+
// cancel this in-flight read. When dbConn is context.trx, an
|
|
322
|
+
// uncancelled read leaks the backend into `idle in transaction`
|
|
323
|
+
// on timeout (matches the d1dde84 save/delete fix, which missed
|
|
324
|
+
// the read path).
|
|
325
|
+
const rows = await runWithSignal<any[]>(
|
|
326
|
+
dbConn`SELECT id, data FROM components WHERE entity_id = ${entity.id} AND type_id = ${typeId} AND deleted_at IS NULL`,
|
|
327
|
+
signal
|
|
328
|
+
);
|
|
329
|
+
if (rows.length > 0) {
|
|
330
|
+
componentData = rows[0].data;
|
|
331
|
+
componentId = rows[0].id;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (componentData !== null) {
|
|
336
|
+
const comp: any = new ctor();
|
|
337
|
+
if (componentId) {
|
|
338
|
+
comp.id = componentId;
|
|
339
|
+
}
|
|
340
|
+
const parsedData = typeof componentData === 'string' ? JSON.parse(componentData) : componentData;
|
|
341
|
+
Object.assign(comp, parsedData);
|
|
342
|
+
const storage = getMetadataStorage();
|
|
343
|
+
const props = storage.componentProperties.get(typeId);
|
|
344
|
+
if (props) {
|
|
345
|
+
for (const prop of props) {
|
|
346
|
+
if (prop.propertyType === Date && typeof comp[prop.propertyKey] === 'string') {
|
|
347
|
+
comp[prop.propertyKey] = new Date(comp[prop.propertyKey]);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
comp.setPersisted(true);
|
|
352
|
+
comp.setDirty(false);
|
|
353
|
+
addComponent(entity, comp);
|
|
354
|
+
return comp as T;
|
|
355
|
+
} else {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
} catch (error) {
|
|
359
|
+
logger.error(`Failed to fetch component ${ctor.name}: ${error}`);
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export { loadComponent };
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Entity loaders, clone/ref factories, and (de)serialization. Extracted
|
|
2
|
+
// from Entity.ts (RFC_REFACTOR_TARGETS §3.2). Functions take/return the
|
|
3
|
+
// Entity instance; the Entity class is imported lazily where construction
|
|
4
|
+
// is needed to avoid a module-eval cycle.
|
|
5
|
+
import { logger } from "../Logger";
|
|
6
|
+
import db from "../../database";
|
|
7
|
+
import ComponentRegistry from "../components/ComponentRegistry";
|
|
8
|
+
import { uuidv7 } from "../../utils/uuid";
|
|
9
|
+
import { sql, SQL } from "bun";
|
|
10
|
+
import { getMetadataStorage } from "../metadata";
|
|
11
|
+
import { addComponent } from "./componentAccess";
|
|
12
|
+
// Value import: the Entity class is only referenced inside function bodies
|
|
13
|
+
// (called at runtime, after module init), so the ESM cycle with Entity.ts
|
|
14
|
+
// resolves via live bindings without a load-order hazard.
|
|
15
|
+
import { Entity } from "../Entity";
|
|
16
|
+
|
|
17
|
+
export async function loadMultiple(ids: string[]): Promise<Entity[]> {
|
|
18
|
+
if (ids.length === 0) return [];
|
|
19
|
+
|
|
20
|
+
// Filter out empty/invalid IDs to prevent PostgreSQL UUID parsing errors
|
|
21
|
+
const validIds = ids.filter(id => id && id.trim() !== '');
|
|
22
|
+
if (validIds.length === 0) return [];
|
|
23
|
+
if (validIds.length !== ids.length) {
|
|
24
|
+
logger.warn(`LoadMultiple: Filtered out ${ids.length - validIds.length} invalid entity IDs`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const components = await db`
|
|
28
|
+
SELECT c.id, c.entity_id, c.type_id, c.data
|
|
29
|
+
FROM components c
|
|
30
|
+
WHERE c.entity_id IN ${sql(validIds)} AND c.deleted_at IS NULL
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
const entitiesMap = new Map<string, Entity>();
|
|
34
|
+
|
|
35
|
+
for (const id of validIds) {
|
|
36
|
+
const entity = new Entity();
|
|
37
|
+
entity.id = id;
|
|
38
|
+
entity.setPersisted(true);
|
|
39
|
+
entity.setDirty(false);
|
|
40
|
+
entitiesMap.set(id, entity);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const row of components) {
|
|
44
|
+
const { id, entity_id, type_id, data } = row;
|
|
45
|
+
const ctor = ComponentRegistry.getConstructor(type_id);
|
|
46
|
+
if (ctor) {
|
|
47
|
+
const comp = new ctor();
|
|
48
|
+
const componentData = typeof data === 'string' ? JSON.parse(data) : data;
|
|
49
|
+
Object.assign(comp, componentData);
|
|
50
|
+
comp.id = id;
|
|
51
|
+
comp.setPersisted(true);
|
|
52
|
+
comp.setDirty(false);
|
|
53
|
+
const target = entitiesMap.get(entity_id);
|
|
54
|
+
if (target) addComponent(target, comp);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return Array.from(entitiesMap.values());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function loadComponents(entities: Entity[], componentIds: string[], skipCache: boolean = false): Promise<void> {
|
|
62
|
+
if (entities.length === 0 || componentIds.length === 0) return;
|
|
63
|
+
|
|
64
|
+
// Filter out entities with empty/invalid IDs to prevent PostgreSQL UUID parsing errors
|
|
65
|
+
const validEntities = entities.filter(e => e.id && e.id.trim() !== '');
|
|
66
|
+
if (validEntities.length === 0) return;
|
|
67
|
+
|
|
68
|
+
const entityIds = validEntities.map(e => e.id);
|
|
69
|
+
|
|
70
|
+
const components = await db`
|
|
71
|
+
SELECT c.id, c.entity_id, c.type_id, c.data
|
|
72
|
+
FROM components c
|
|
73
|
+
WHERE c.entity_id IN ${sql(entityIds)} AND c.type_id IN ${sql(componentIds)} AND c.deleted_at IS NULL
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
// Use Map for O(1) lookups instead of O(n) find() - fixes O(n²) performance issue
|
|
77
|
+
const entityMap = new Map<string, Entity>(validEntities.map(e => [e.id, e]));
|
|
78
|
+
|
|
79
|
+
for (const row of components) {
|
|
80
|
+
const { id, entity_id, type_id, data } = row;
|
|
81
|
+
const entity = entityMap.get(entity_id); // O(1) instead of O(n)
|
|
82
|
+
if (entity) {
|
|
83
|
+
const ctor = ComponentRegistry.getConstructor(type_id);
|
|
84
|
+
if (ctor) {
|
|
85
|
+
const comp = new ctor();
|
|
86
|
+
const componentData = typeof data === 'string' ? JSON.parse(data) : data;
|
|
87
|
+
Object.assign(comp, componentData);
|
|
88
|
+
comp.id = id;
|
|
89
|
+
comp.setPersisted(true);
|
|
90
|
+
comp.setDirty(false);
|
|
91
|
+
addComponent(entity, comp);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Find an entity by its ID. Returning populated with all components. Or null if not found.
|
|
99
|
+
*/
|
|
100
|
+
export async function findById(id: string, trx?: SQL): Promise<Entity | null> {
|
|
101
|
+
// Validate ID to prevent PostgreSQL UUID parsing errors
|
|
102
|
+
if (!id || typeof id !== 'string' || id.trim() === '') {
|
|
103
|
+
logger.warn(`FindById called with invalid id: "${id}"`);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const { Query } = await import("../../query/Query");
|
|
107
|
+
const entities = await new Query(trx).findById(id).populate().exec()
|
|
108
|
+
if (entities.length === 1) {
|
|
109
|
+
return entities[0]!;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function clone(entity: Entity): Entity {
|
|
115
|
+
const clone = new Entity();
|
|
116
|
+
clone.setDirty(true);
|
|
117
|
+
clone.setPersisted(false);
|
|
118
|
+
for (const comp of entity.components.values()) {
|
|
119
|
+
const newComp = new (comp.constructor as any)();
|
|
120
|
+
Object.assign(newComp, comp.data());
|
|
121
|
+
newComp.id = uuidv7();
|
|
122
|
+
newComp.setDirty(true);
|
|
123
|
+
newComp.setPersisted(false);
|
|
124
|
+
addComponent(clone, newComp);
|
|
125
|
+
}
|
|
126
|
+
return clone;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function makeRef(entity: Entity): Entity {
|
|
130
|
+
const ref = new Entity();
|
|
131
|
+
ref.setDirty(true);
|
|
132
|
+
ref.setPersisted(false);
|
|
133
|
+
for (const comp of entity.components.values()) {
|
|
134
|
+
const refComp = comp;
|
|
135
|
+
refComp.setDirty(false);
|
|
136
|
+
refComp.setPersisted(true);
|
|
137
|
+
addComponent(ref, refComp);
|
|
138
|
+
}
|
|
139
|
+
return ref;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Serialize the entity with only the currently loaded components
|
|
144
|
+
*/
|
|
145
|
+
export function serialize(entity: Entity): { id: string; components: Record<string, any> } {
|
|
146
|
+
const components: Record<string, any> = {};
|
|
147
|
+
for (const comp of entity.components.values()) {
|
|
148
|
+
components[comp.constructor.name] = comp.serializableData();
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
id: entity.id,
|
|
152
|
+
components
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Deserialize/reconstitute an Entity from cached/serialized data.
|
|
158
|
+
*/
|
|
159
|
+
export function deserialize(data: any): Entity {
|
|
160
|
+
if (data instanceof Entity) {
|
|
161
|
+
return data;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const entity = new Entity(data.id);
|
|
165
|
+
entity.setPersisted(true);
|
|
166
|
+
entity.setDirty(false);
|
|
167
|
+
|
|
168
|
+
// Handle serialized format: { id, components: { ComponentName: {...data} } }
|
|
169
|
+
if (data.components && typeof data.components === 'object') {
|
|
170
|
+
const storage = getMetadataStorage();
|
|
171
|
+
|
|
172
|
+
for (const [componentName, componentData] of Object.entries(data.components)) {
|
|
173
|
+
// Find the component constructor by name
|
|
174
|
+
const ComponentCtor = ComponentRegistry.getConstructorByName(componentName);
|
|
175
|
+
if (!ComponentCtor) {
|
|
176
|
+
logger.warn(`Cannot deserialize component: constructor not found for ${componentName}`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const comp = new ComponentCtor();
|
|
181
|
+
const parsedData = typeof componentData === 'string' ? JSON.parse(componentData) : componentData;
|
|
182
|
+
Object.assign(comp, parsedData);
|
|
183
|
+
|
|
184
|
+
// Restore Date objects
|
|
185
|
+
const typeId = comp.getTypeID();
|
|
186
|
+
const props = storage.componentProperties.get(typeId);
|
|
187
|
+
if (props) {
|
|
188
|
+
for (const prop of props) {
|
|
189
|
+
if (prop.propertyType === Date && typeof (comp as any)[prop.propertyKey] === 'string') {
|
|
190
|
+
(comp as any)[prop.propertyKey] = new Date((comp as any)[prop.propertyKey]);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
comp.setPersisted(true);
|
|
196
|
+
comp.setDirty(false);
|
|
197
|
+
addComponent(entity, comp);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return entity;
|
|
202
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Drainable background-work tracking for Entity. Extracted from Entity.ts
|
|
2
|
+
// (RFC_REFACTOR_TARGETS §3.2). Module-level state owns the Sets; Entity
|
|
3
|
+
// keeps thin public static delegates for external callers.
|
|
4
|
+
|
|
5
|
+
// Drainable set of fire-and-forget cache ops triggered from set/remove.
|
|
6
|
+
// App.shutdown can await these to avoid losing writes mid-shutdown
|
|
7
|
+
// (H-CACHE-1).
|
|
8
|
+
const pendingCacheOps: Set<Promise<void>> = new Set();
|
|
9
|
+
|
|
10
|
+
// Drainable set of post-commit side-effect Promises scheduled via
|
|
11
|
+
// queueMicrotask from save(). Includes cache invalidation + lifecycle
|
|
12
|
+
// hooks (EntityCreated / EntityUpdated). Hooks may transitively trigger
|
|
13
|
+
// more DB work (e.g., entity.save() from a handler), which is why this
|
|
14
|
+
// is tracked separately from pendingCacheOps. Tests running against
|
|
15
|
+
// PGlite's single-connection pool should drain this between test files
|
|
16
|
+
// to prevent background work from prior files queueing behind the
|
|
17
|
+
// current file's save and masking visibility of recently-committed
|
|
18
|
+
// rows. See BUNSANE-001.
|
|
19
|
+
const pendingSideEffects: Set<Promise<void>> = new Set();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Await all pending background cache operations. Call during shutdown
|
|
23
|
+
* after HTTP drain but before cache.disconnect so setImmediate'd cache
|
|
24
|
+
* writes are not lost. Bounded by `timeoutMs`.
|
|
25
|
+
*/
|
|
26
|
+
export async function drainPendingCacheOps(timeoutMs: number = 5_000): Promise<void> {
|
|
27
|
+
if (pendingCacheOps.size === 0) return;
|
|
28
|
+
const snapshot = [...pendingCacheOps];
|
|
29
|
+
const drainTimer = new Promise<'timeout'>((resolve) => {
|
|
30
|
+
const t = setTimeout(() => resolve('timeout'), timeoutMs);
|
|
31
|
+
t.unref?.();
|
|
32
|
+
});
|
|
33
|
+
await Promise.race([
|
|
34
|
+
Promise.allSettled(snapshot).then(() => 'drained' as const),
|
|
35
|
+
drainTimer,
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Await all pending post-commit side effects (cache invalidation +
|
|
41
|
+
* lifecycle hooks scheduled via queueMicrotask from save()). Call from
|
|
42
|
+
* test setup/teardown hooks under PGlite to guarantee prior-file
|
|
43
|
+
* background work has settled before the next file's saves run. Bounded
|
|
44
|
+
* by `timeoutMs`. Safe to call repeatedly; no-op when the set is empty.
|
|
45
|
+
*/
|
|
46
|
+
export async function drainPendingSideEffects(timeoutMs: number = 5_000): Promise<void> {
|
|
47
|
+
if (pendingSideEffects.size === 0) return;
|
|
48
|
+
const snapshot = [...pendingSideEffects];
|
|
49
|
+
const drainTimer = new Promise<'timeout'>((resolve) => {
|
|
50
|
+
const t = setTimeout(() => resolve('timeout'), timeoutMs);
|
|
51
|
+
t.unref?.();
|
|
52
|
+
});
|
|
53
|
+
await Promise.race([
|
|
54
|
+
Promise.allSettled(snapshot).then(() => 'drained' as const),
|
|
55
|
+
drainTimer,
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Track a fire-and-forget cache promise in the drainable set. Public so
|
|
61
|
+
* other framework read paths (e.g. Query.populateComponents cache
|
|
62
|
+
* warming) share the same drain semantics (H-CACHE-1).
|
|
63
|
+
*/
|
|
64
|
+
export function trackCacheOp(p: Promise<void>): void {
|
|
65
|
+
pendingCacheOps.add(p);
|
|
66
|
+
p.finally(() => pendingCacheOps.delete(p));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function trackSideEffect(p: Promise<void>): void {
|
|
70
|
+
pendingSideEffects.add(p);
|
|
71
|
+
p.finally(() => pendingSideEffects.delete(p));
|
|
72
|
+
}
|