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,377 @@
|
|
|
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 fire the UPDATEs together via Promise.all so they
|
|
272
|
+
// pipeline on the transaction connection instead of paying one
|
|
273
|
+
// serial round-trip per dirty component.
|
|
274
|
+
if (componentsToUpdate.length > 0) {
|
|
275
|
+
const traceEnabled = logger.isLevelEnabled?.('trace') === true;
|
|
276
|
+
for (const comp of componentsToUpdate) {
|
|
277
|
+
// Validate component ID to prevent PostgreSQL UUID parsing errors
|
|
278
|
+
if (!comp.id || comp.id.trim() === '') {
|
|
279
|
+
logger.error(`Cannot update component: id is empty or invalid. Component data: ${JSON.stringify(comp.data).substring(0, 200)}`);
|
|
280
|
+
throw new Error(`Cannot update component: component id is empty or invalid`);
|
|
281
|
+
}
|
|
282
|
+
// Level-gated: per-component log-object allocation in the
|
|
283
|
+
// write hot path is pure waste when trace is off.
|
|
284
|
+
if (traceEnabled) {
|
|
285
|
+
logger.trace({ componentId: comp.id, data: comp.data }, `[Entity.doSave] Updating component`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
await Promise.all(
|
|
289
|
+
componentsToUpdate.map(comp =>
|
|
290
|
+
run(saveTrx`UPDATE components SET data = ${comp.data} WHERE id = ${comp.id}`)
|
|
291
|
+
)
|
|
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 — pipeline the
|
|
326
|
+
// statements on the transaction connection instead of paying
|
|
327
|
+
// serial round-trips while holding the connection.
|
|
328
|
+
if (force) {
|
|
329
|
+
await Promise.all([
|
|
330
|
+
run(trx`DELETE FROM components WHERE entity_id = ${entity.id}`),
|
|
331
|
+
run(trx`DELETE FROM entities WHERE id = ${entity.id}`),
|
|
332
|
+
]);
|
|
333
|
+
} else {
|
|
334
|
+
await Promise.all([
|
|
335
|
+
run(trx`UPDATE entities SET deleted_at = CURRENT_TIMESTAMP WHERE id = ${entity.id} AND deleted_at IS NULL`),
|
|
336
|
+
run(trx`UPDATE components SET deleted_at = CURRENT_TIMESTAMP WHERE entity_id = ${entity.id} AND deleted_at IS NULL`),
|
|
337
|
+
]);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
clearTimeout(timeoutHandle);
|
|
341
|
+
|
|
342
|
+
// Fire-and-forget post-commit side effects: lifecycle hooks + cache
|
|
343
|
+
// invalidation. Errors are logged, never propagate to caller.
|
|
344
|
+
queueMicrotask(() => runPostDeleteSideEffects(entity, !force));
|
|
345
|
+
|
|
346
|
+
return true;
|
|
347
|
+
} catch (error) {
|
|
348
|
+
clearTimeout(timeoutHandle);
|
|
349
|
+
if (signal.aborted) {
|
|
350
|
+
logger.error({ scope: 'Entity.doDelete', entityId: entity.id }, `Entity delete aborted: ${signal.reason ?? error}`);
|
|
351
|
+
} else {
|
|
352
|
+
logger.error({ scope: 'Entity.doDelete', entityId: entity.id, err: error }, 'Failed to delete entity');
|
|
353
|
+
}
|
|
354
|
+
// Re-throw so callers can distinguish DB failures (pool exhausted,
|
|
355
|
+
// lock timeout, etc.) from "entity not found" / not persisted,
|
|
356
|
+
// which still returns `false`. Previously any error produced the
|
|
357
|
+
// same `false` return, hiding infrastructure problems (H-OBS-4).
|
|
358
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
359
|
+
} finally {
|
|
360
|
+
if (!signal.aborted) controller.abort();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Get list of component type IDs that are dirty
|
|
366
|
+
*/
|
|
367
|
+
export function getDirtyComponents(entity: Entity): string[] {
|
|
368
|
+
const dirtyComponents: string[] = [];
|
|
369
|
+
for (const component of entity.components.values()) {
|
|
370
|
+
// Include both dirty (modified) components AND new (not persisted) components
|
|
371
|
+
// New components need to be cached after save, not just modified ones
|
|
372
|
+
if ((component as any)._dirty || !(component as any)._persisted) {
|
|
373
|
+
dirtyComponents.push(component.getTypeID());
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return dirtyComponents;
|
|
377
|
+
}
|