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,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,10 @@
|
|
|
1
|
+
// Static import of CacheManager for hot-path use in componentAccess and
|
|
2
|
+
// cacheStrategies. CacheManager's imports are all type-only references back
|
|
3
|
+
// to core/Entity, so there is no runtime circular dependency — static import
|
|
4
|
+
// is safe and avoids the microtask + Promise allocation of a dynamic import
|
|
5
|
+
// on every set/remove/save/delete call.
|
|
6
|
+
import { CacheManager } from '../cache/CacheManager';
|
|
7
|
+
|
|
8
|
+
export function getCacheManager(): typeof CacheManager {
|
|
9
|
+
return CacheManager;
|
|
10
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// Persistence path for Entity (save / doSave / doDelete) and post-commit
|
|
2
|
+
// side effects. Extracted from Entity.ts (RFC_REFACTOR_TARGETS §3.2). This
|
|
3
|
+
// is the framework's hottest path — behavior is byte-identical to the
|
|
4
|
+
// original inline implementation. Pure functions take the entity instance
|
|
5
|
+
// as the first parameter.
|
|
6
|
+
import { logger } from "../Logger";
|
|
7
|
+
import db, { QUERY_TIMEOUT_MS } from "../../database";
|
|
8
|
+
import { runWithSignal } from "../../database/cancellable";
|
|
9
|
+
import ComponentRegistry from "../components/ComponentRegistry";
|
|
10
|
+
import { uuidv7 } from "../../utils/uuid";
|
|
11
|
+
import { sql, SQL } from "bun";
|
|
12
|
+
import EntityHookManager from "../EntityHookManager";
|
|
13
|
+
import { EntityCreatedEvent, EntityUpdatedEvent } from "../events/EntityLifecycleEvents";
|
|
14
|
+
import { trackSideEffect } from "./pendingOps";
|
|
15
|
+
import { handleCacheAfterSave, runPostDeleteSideEffects } from "./cacheStrategies";
|
|
16
|
+
import type { Entity } from "../Entity";
|
|
17
|
+
|
|
18
|
+
export async function saveEntity(entity: Entity, trx?: SQL, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): Promise<boolean> {
|
|
19
|
+
// Capture pre-save state BEFORE doSave mutates persisted/dirty flags.
|
|
20
|
+
const wasNew = !entity._persisted;
|
|
21
|
+
const changedComponentTypeIds = getDirtyComponents(entity);
|
|
22
|
+
const removedComponentTypeIds = Array.from(entity.removedComponents);
|
|
23
|
+
|
|
24
|
+
// Pre-flight: await ComponentRegistry readiness for every component on
|
|
25
|
+
// this entity BEFORE opening the transaction. Previously doSave awaited
|
|
26
|
+
// ComponentRegistry.getReadyPromise inside the executeSave loop, so a
|
|
27
|
+
// slow DDL (partition creation) would keep the PG transaction open and
|
|
28
|
+
// idle-in-transaction waiting on registry state. (H-DB-4).
|
|
29
|
+
for (const comp of entity.components.values()) {
|
|
30
|
+
const compName = comp.constructor.name;
|
|
31
|
+
if (!ComponentRegistry.isComponentReady(compName)) {
|
|
32
|
+
await ComponentRegistry.getReadyPromise(compName);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const profile = process.env.DB_SAVE_PROFILE === 'true';
|
|
37
|
+
const phaseStart = profile ? performance.now() : 0;
|
|
38
|
+
const phases: Record<string, number> = {};
|
|
39
|
+
|
|
40
|
+
// AbortController cancels in-flight queries and propagates ROLLBACK
|
|
41
|
+
// when the wall-clock timer fires. Throwing from inside the transaction
|
|
42
|
+
// callback triggers Bun SQL's auto-ROLLBACK, releasing the pooled connection.
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
const timeoutMs = QUERY_TIMEOUT_MS;
|
|
45
|
+
const timeoutHandle = setTimeout(() => {
|
|
46
|
+
const err = new Error(`Entity save timeout for entity ${entity.id} after ${timeoutMs}ms`);
|
|
47
|
+
logger.error({ scope: 'Entity.save', entityId: entity.id, timeoutMs }, err.message);
|
|
48
|
+
controller.abort(err);
|
|
49
|
+
}, timeoutMs);
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const dbStart = profile ? performance.now() : 0;
|
|
53
|
+
if (trx) {
|
|
54
|
+
await doSave(entity, trx, controller.signal);
|
|
55
|
+
} else {
|
|
56
|
+
await db.transaction(async (newTrx) => {
|
|
57
|
+
await doSave(entity, newTrx, controller.signal);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (profile) phases.db = performance.now() - dbStart;
|
|
61
|
+
|
|
62
|
+
clearTimeout(timeoutHandle);
|
|
63
|
+
|
|
64
|
+
// Post-commit side effects are fire-and-forget so Redis / hook
|
|
65
|
+
// latency cannot consume the save budget or block the caller.
|
|
66
|
+
// Tracked in pendingSideEffects so tests/shutdown can drain
|
|
67
|
+
// background work before asserting or tearing down.
|
|
68
|
+
const sideEffectPromise = new Promise<void>((resolve) => {
|
|
69
|
+
queueMicrotask(() => {
|
|
70
|
+
runPostCommitSideEffects(
|
|
71
|
+
entity,
|
|
72
|
+
wasNew,
|
|
73
|
+
changedComponentTypeIds,
|
|
74
|
+
removedComponentTypeIds,
|
|
75
|
+
context,
|
|
76
|
+
profile ? phases : undefined,
|
|
77
|
+
profile ? phaseStart : undefined,
|
|
78
|
+
).finally(() => resolve());
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
trackSideEffect(sideEffectPromise);
|
|
82
|
+
|
|
83
|
+
return true;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
clearTimeout(timeoutHandle);
|
|
86
|
+
if (controller.signal.aborted) {
|
|
87
|
+
throw controller.signal.reason ?? error;
|
|
88
|
+
}
|
|
89
|
+
throw error;
|
|
90
|
+
} finally {
|
|
91
|
+
// Ensure AbortController listeners are released even on success.
|
|
92
|
+
if (!controller.signal.aborted) controller.abort();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Fire-and-forget post-commit work: cache invalidation + lifecycle hooks.
|
|
98
|
+
* Runs outside the save budget. Errors are logged and swallowed so cache
|
|
99
|
+
* or hook failures never surface as save failures.
|
|
100
|
+
*/
|
|
101
|
+
async function runPostCommitSideEffects(
|
|
102
|
+
entity: Entity,
|
|
103
|
+
wasNew: boolean,
|
|
104
|
+
changedComponentTypeIds: string[],
|
|
105
|
+
removedComponentTypeIds: string[],
|
|
106
|
+
context: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal } | undefined,
|
|
107
|
+
phases: Record<string, number> | undefined,
|
|
108
|
+
phaseStart: number | undefined,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
const profile = phases !== undefined && phaseStart !== undefined;
|
|
111
|
+
|
|
112
|
+
const cacheStart = profile ? performance.now() : 0;
|
|
113
|
+
try {
|
|
114
|
+
await handleCacheAfterSave(entity, changedComponentTypeIds, removedComponentTypeIds, context);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
logger.warn({ scope: 'cache', entityId: entity.id, err }, 'post-commit cache invalidation failed');
|
|
117
|
+
}
|
|
118
|
+
if (profile) phases!.cache = performance.now() - cacheStart;
|
|
119
|
+
|
|
120
|
+
const hookStart = profile ? performance.now() : 0;
|
|
121
|
+
try {
|
|
122
|
+
if (wasNew) {
|
|
123
|
+
await EntityHookManager.executeHooks(new EntityCreatedEvent(entity));
|
|
124
|
+
} else if (changedComponentTypeIds.length > 0) {
|
|
125
|
+
await EntityHookManager.executeHooks(new EntityUpdatedEvent(entity, changedComponentTypeIds));
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
logger.error({ scope: 'hooks', entityId: entity.id, err }, 'post-commit lifecycle hooks failed');
|
|
129
|
+
}
|
|
130
|
+
if (profile) phases!.hooks = performance.now() - hookStart;
|
|
131
|
+
|
|
132
|
+
if (profile) {
|
|
133
|
+
phases!.total = performance.now() - phaseStart!;
|
|
134
|
+
logger.info({ scope: 'Entity.save.profile', entityId: entity.id, phases }, 'Entity.save phase timings');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function doSave(entity: Entity, trx: SQL, signal?: AbortSignal): Promise<boolean> {
|
|
139
|
+
// Validate entity ID to prevent PostgreSQL UUID parsing errors
|
|
140
|
+
if (!entity.id || entity.id.trim() === '') {
|
|
141
|
+
logger.error(`Cannot save entity: id is empty or invalid`);
|
|
142
|
+
throw new Error(`Cannot save entity: id is empty or invalid`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!(entity as any)._dirty) {
|
|
146
|
+
// Diagnostics object is non-trivial to build (component walk +
|
|
147
|
+
// preview mapping) — gate on the active level so the not-dirty
|
|
148
|
+
// fast path stays allocation-free in production.
|
|
149
|
+
if (logger.isLevelEnabled?.('trace')) {
|
|
150
|
+
let dirtyComponents: string[] = [];
|
|
151
|
+
try {
|
|
152
|
+
dirtyComponents = getDirtyComponents(entity);
|
|
153
|
+
} catch {
|
|
154
|
+
// best-effort diagnostics only
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const removedTypeIds = Array.from(entity.removedComponents);
|
|
158
|
+
const entityType = (entity as any)?.constructor?.name ?? "Entity";
|
|
159
|
+
const dirtyComponentPreview = dirtyComponents.slice(0, 10).map((component) => {
|
|
160
|
+
const anyComponent = component as any;
|
|
161
|
+
return {
|
|
162
|
+
type: anyComponent?.constructor?.name ?? "Component",
|
|
163
|
+
typeId: typeof anyComponent?.getTypeID === "function" ? anyComponent.getTypeID() : undefined,
|
|
164
|
+
id: anyComponent?.id,
|
|
165
|
+
persisted: anyComponent?._persisted,
|
|
166
|
+
dirty: anyComponent?._dirty,
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
logger.trace(
|
|
171
|
+
{
|
|
172
|
+
component: "Entity",
|
|
173
|
+
entity: {
|
|
174
|
+
type: entityType,
|
|
175
|
+
id: entity.id,
|
|
176
|
+
persisted: entity._persisted,
|
|
177
|
+
dirty: (entity as any)._dirty,
|
|
178
|
+
},
|
|
179
|
+
components: {
|
|
180
|
+
total: entity.components.size,
|
|
181
|
+
dirtyCount: dirtyComponents.length,
|
|
182
|
+
dirtyPreview: dirtyComponentPreview,
|
|
183
|
+
},
|
|
184
|
+
removedComponents: {
|
|
185
|
+
count: removedTypeIds.length,
|
|
186
|
+
typeIdsPreview: removedTypeIds.slice(0, 10),
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
"[Entity.doSave] Skipping save because entity is not dirty"
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Cancellation goes through the shared `runWithSignal` helper so
|
|
196
|
+
// every db.unsafe / trx`...` callsite in the framework uses the same
|
|
197
|
+
// pattern: on abort the in-flight Bun SQL Query is cancelled, the
|
|
198
|
+
// transaction callback throws, Bun emits ROLLBACK, and the pooled
|
|
199
|
+
// backend connection is released. Without this a wall-clock timeout
|
|
200
|
+
// leaks the backend into `idle in transaction` under pgbouncer
|
|
201
|
+
// transaction-mode pooling.
|
|
202
|
+
const run = <T>(q: any): Promise<T> => runWithSignal<T>(q, signal);
|
|
203
|
+
|
|
204
|
+
const executeSave = async (saveTrx: SQL) => {
|
|
205
|
+
if (!entity._persisted) {
|
|
206
|
+
await run(saveTrx`INSERT INTO entities (id) VALUES (${entity.id}) ON CONFLICT DO NOTHING`);
|
|
207
|
+
entity._persisted = true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Delete removed components from database. `components` is the
|
|
211
|
+
// single source of membership truth — one DELETE per removal batch.
|
|
212
|
+
if (entity.removedComponents.size > 0) {
|
|
213
|
+
const typeIds = Array.from(entity.removedComponents);
|
|
214
|
+
await run(saveTrx`DELETE FROM components WHERE entity_id = ${entity.id} AND type_id IN ${sql(typeIds)}`);
|
|
215
|
+
// Move to savedRemovedComponents so resolvers can still detect removed components
|
|
216
|
+
// This is needed because DataLoader may have stale cached data for this request
|
|
217
|
+
for (const typeId of typeIds) {
|
|
218
|
+
entity.savedRemovedComponents.add(typeId);
|
|
219
|
+
}
|
|
220
|
+
entity.removedComponents.clear();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (entity.components.size === 0) {
|
|
224
|
+
logger.trace(`No components to save for entity ${entity.id}`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Batch inserts and updates for better performance
|
|
229
|
+
const componentsToInsert = [];
|
|
230
|
+
const componentsToUpdate = [];
|
|
231
|
+
|
|
232
|
+
for (const comp of entity.components.values()) {
|
|
233
|
+
const compName = comp.constructor.name;
|
|
234
|
+
// Registry readiness is pre-flighted in save() before the
|
|
235
|
+
// transaction starts (H-DB-4). This assert catches a
|
|
236
|
+
// theoretical race if a caller skipped save() and jumped
|
|
237
|
+
// straight to doSave — we refuse to await inside the txn so
|
|
238
|
+
// a slow DDL cannot hold a pg session idle in transaction.
|
|
239
|
+
if (!ComponentRegistry.isComponentReady(compName)) {
|
|
240
|
+
throw new Error(`Component ${compName} not ready; call save() (not doSave) or await registry readiness before the transaction.`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!(comp as any)._persisted) {
|
|
244
|
+
if (comp.id === "") {
|
|
245
|
+
comp.id = uuidv7();
|
|
246
|
+
}
|
|
247
|
+
componentsToInsert.push({
|
|
248
|
+
id: comp.id,
|
|
249
|
+
entity_id: entity.id,
|
|
250
|
+
name: compName,
|
|
251
|
+
type_id: comp.getTypeID(),
|
|
252
|
+
data: comp.serializableData()
|
|
253
|
+
});
|
|
254
|
+
(comp as any).setPersisted(true);
|
|
255
|
+
(comp as any).setDirty(false);
|
|
256
|
+
} else if ((comp as any)._dirty) {
|
|
257
|
+
componentsToUpdate.push({
|
|
258
|
+
id: comp.id,
|
|
259
|
+
data: comp.serializableData()
|
|
260
|
+
});
|
|
261
|
+
(comp as any).setDirty(false);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Perform batch inserts
|
|
266
|
+
if (componentsToInsert.length > 0) {
|
|
267
|
+
await run(saveTrx`INSERT INTO components ${sql(componentsToInsert, 'id', 'entity_id', 'name', 'type_id', 'data')}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Perform updates. Validate all ids up front (synchronous, fails
|
|
271
|
+
// fast), then issue the UPDATEs sequentially. They were previously
|
|
272
|
+
// fired together via Promise.all to "pipeline" on the transaction
|
|
273
|
+
// connection, but multiple concurrent in-flight queries on one
|
|
274
|
+
// connection deadlock single-backend servers (PGlite test harness),
|
|
275
|
+
// and a single wire serializes them regardless — no real gain.
|
|
276
|
+
if (componentsToUpdate.length > 0) {
|
|
277
|
+
const traceEnabled = logger.isLevelEnabled?.('trace') === true;
|
|
278
|
+
for (const comp of componentsToUpdate) {
|
|
279
|
+
// Validate component ID to prevent PostgreSQL UUID parsing errors
|
|
280
|
+
if (!comp.id || comp.id.trim() === '') {
|
|
281
|
+
logger.error(`Cannot update component: id is empty or invalid. Component data: ${JSON.stringify(comp.data).substring(0, 200)}`);
|
|
282
|
+
throw new Error(`Cannot update component: component id is empty or invalid`);
|
|
283
|
+
}
|
|
284
|
+
// Level-gated: per-component log-object allocation in the
|
|
285
|
+
// write hot path is pure waste when trace is off.
|
|
286
|
+
if (traceEnabled) {
|
|
287
|
+
logger.trace({ componentId: comp.id, data: comp.data }, `[Entity.doSave] Updating component`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
for (const comp of componentsToUpdate) {
|
|
291
|
+
await run(saveTrx`UPDATE components SET data = ${comp.data} WHERE id = ${comp.id}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
await executeSave(trx);
|
|
297
|
+
|
|
298
|
+
entity.setDirty(false);
|
|
299
|
+
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function doDelete(entity: Entity, force: boolean = false): Promise<boolean> {
|
|
304
|
+
if (!entity._persisted) {
|
|
305
|
+
logger.warn("Entity is not persisted, cannot delete.");
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// AbortController cancels in-flight queries on wall-clock timeout so a
|
|
310
|
+
// hanging DELETE cannot leak backends into `idle in transaction` under
|
|
311
|
+
// pgbouncer transaction pool mode. Same pattern as Entity.save.
|
|
312
|
+
const controller = new AbortController();
|
|
313
|
+
const timeoutMs = QUERY_TIMEOUT_MS;
|
|
314
|
+
const timeoutHandle = setTimeout(() => {
|
|
315
|
+
const err = new Error(`Entity delete timeout for entity ${entity.id} after ${timeoutMs}ms`);
|
|
316
|
+
logger.error({ scope: 'Entity.doDelete', entityId: entity.id, timeoutMs }, err.message);
|
|
317
|
+
controller.abort(err);
|
|
318
|
+
}, timeoutMs);
|
|
319
|
+
|
|
320
|
+
const signal = controller.signal;
|
|
321
|
+
const run = <T>(q: any): Promise<T> => runWithSignal<T>(q, signal);
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
await db.transaction(async (trx) => {
|
|
325
|
+
// Independent tables, no FK constraints. Issued sequentially:
|
|
326
|
+
// multiple concurrent in-flight queries on one connection
|
|
327
|
+
// deadlock single-backend servers (PGlite test harness), and a
|
|
328
|
+
// single wire serializes them anyway — Promise.all gave no real
|
|
329
|
+
// pipelining here.
|
|
330
|
+
if (force) {
|
|
331
|
+
await run(trx`DELETE FROM components WHERE entity_id = ${entity.id}`);
|
|
332
|
+
await run(trx`DELETE FROM entities WHERE id = ${entity.id}`);
|
|
333
|
+
} else {
|
|
334
|
+
await run(trx`UPDATE entities SET deleted_at = CURRENT_TIMESTAMP WHERE id = ${entity.id} AND deleted_at IS NULL`);
|
|
335
|
+
await run(trx`UPDATE components SET deleted_at = CURRENT_TIMESTAMP WHERE entity_id = ${entity.id} AND deleted_at IS NULL`);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
clearTimeout(timeoutHandle);
|
|
339
|
+
|
|
340
|
+
// Fire-and-forget post-commit side effects: lifecycle hooks + cache
|
|
341
|
+
// invalidation. Errors are logged, never propagate to caller.
|
|
342
|
+
queueMicrotask(() => runPostDeleteSideEffects(entity, !force));
|
|
343
|
+
|
|
344
|
+
return true;
|
|
345
|
+
} catch (error) {
|
|
346
|
+
clearTimeout(timeoutHandle);
|
|
347
|
+
if (signal.aborted) {
|
|
348
|
+
logger.error({ scope: 'Entity.doDelete', entityId: entity.id }, `Entity delete aborted: ${signal.reason ?? error}`);
|
|
349
|
+
} else {
|
|
350
|
+
logger.error({ scope: 'Entity.doDelete', entityId: entity.id, err: error }, 'Failed to delete entity');
|
|
351
|
+
}
|
|
352
|
+
// Re-throw so callers can distinguish DB failures (pool exhausted,
|
|
353
|
+
// lock timeout, etc.) from "entity not found" / not persisted,
|
|
354
|
+
// which still returns `false`. Previously any error produced the
|
|
355
|
+
// same `false` return, hiding infrastructure problems (H-OBS-4).
|
|
356
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
357
|
+
} finally {
|
|
358
|
+
if (!signal.aborted) controller.abort();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Get list of component type IDs that are dirty
|
|
364
|
+
*/
|
|
365
|
+
export function getDirtyComponents(entity: Entity): string[] {
|
|
366
|
+
const dirtyComponents: string[] = [];
|
|
367
|
+
for (const component of entity.components.values()) {
|
|
368
|
+
// Include both dirty (modified) components AND new (not persisted) components
|
|
369
|
+
// New components need to be cached after save, not just modified ones
|
|
370
|
+
if ((component as any)._dirty || !(component as any)._persisted) {
|
|
371
|
+
dirtyComponents.push(component.getTypeID());
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return dirtyComponents;
|
|
375
|
+
}
|