bunsane 0.2.10 → 0.3.1
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 +318 -0
- package/CLAUDE.md +20 -0
- package/config/cache.config.ts +12 -2
- package/core/App.ts +300 -69
- package/core/ApplicationLifecycle.ts +68 -4
- package/core/Entity.ts +525 -256
- package/core/EntityHookManager.ts +88 -21
- package/core/EntityManager.ts +12 -3
- package/core/Logger.ts +4 -0
- package/core/RequestContext.ts +4 -1
- package/core/SchedulerManager.ts +105 -22
- package/core/cache/CacheFactory.ts +3 -1
- package/core/cache/CacheManager.ts +72 -17
- package/core/cache/RedisCache.ts +38 -3
- package/core/components/BaseComponent.ts +12 -2
- package/core/decorators/EntityHooks.ts +24 -12
- package/core/middleware/RateLimit.ts +105 -0
- package/core/middleware/index.ts +1 -0
- package/core/remote/OutboxWorker.ts +42 -35
- package/core/scheduler/DistributedLock.ts +22 -7
- package/database/PreparedStatementCache.ts +5 -13
- package/gql/builders/ResolverBuilder.ts +4 -4
- package/gql/complexityLimit.ts +95 -0
- package/gql/index.ts +15 -3
- package/gql/visitors/ResolverGeneratorVisitor.ts +16 -2
- package/package.json +1 -1
- package/query/ComponentInclusionNode.ts +18 -11
- package/query/OrNode.ts +2 -4
- package/query/Query.ts +42 -31
- package/query/SqlIdentifier.ts +105 -0
- package/query/builders/FullTextSearchBuilder.ts +19 -6
- package/service/ServiceRegistry.ts +28 -9
- package/service/index.ts +4 -2
- package/storage/LocalStorageProvider.ts +12 -3
- package/storage/S3StorageProvider.ts +6 -6
- package/tests/e2e/http.test.ts +6 -2
- package/tests/integration/entity/Entity.saveTimeout.test.ts +110 -0
- package/tests/unit/cache/CacheManager.test.ts +20 -0
- package/tests/unit/entity/Entity.components.test.ts +73 -0
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +51 -0
- package/tests/unit/entity/Entity.reload.test.ts +63 -0
- package/tests/unit/entity/Entity.requireComponents.test.ts +72 -0
- package/tests/unit/query/Query.emptyString.test.ts +69 -0
- package/tests/unit/query/Query.test.ts +6 -4
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +95 -0
- package/tests/unit/storage/S3StorageProvider.test.ts +6 -10
- package/upload/FileValidator.ts +9 -6
package/core/Entity.ts
CHANGED
|
@@ -22,6 +22,70 @@ export class Entity implements IEntity {
|
|
|
22
22
|
private savedRemovedComponents: Set<string> = new Set<string>();
|
|
23
23
|
protected _dirty: boolean = false;
|
|
24
24
|
|
|
25
|
+
// Drainable set of fire-and-forget cache ops triggered from set/remove.
|
|
26
|
+
// App.shutdown can await these to avoid losing writes mid-shutdown
|
|
27
|
+
// (H-CACHE-1).
|
|
28
|
+
private static pendingCacheOps: Set<Promise<void>> = new Set();
|
|
29
|
+
|
|
30
|
+
// Drainable set of post-commit side-effect Promises scheduled via
|
|
31
|
+
// queueMicrotask from save(). Includes cache invalidation + lifecycle
|
|
32
|
+
// hooks (EntityCreated / EntityUpdated). Hooks may transitively trigger
|
|
33
|
+
// more DB work (e.g., entity.save() from a handler), which is why this
|
|
34
|
+
// is tracked separately from pendingCacheOps. Tests running against
|
|
35
|
+
// PGlite's single-connection pool should drain this between test files
|
|
36
|
+
// to prevent background work from prior files queueing behind the
|
|
37
|
+
// current file's save and masking visibility of recently-committed
|
|
38
|
+
// rows. See BUNSANE-001.
|
|
39
|
+
private static pendingSideEffects: Set<Promise<void>> = new Set();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Await all pending background cache operations. Call during shutdown
|
|
43
|
+
* after HTTP drain but before cache.disconnect so setImmediate'd cache
|
|
44
|
+
* writes are not lost. Bounded by `timeoutMs`.
|
|
45
|
+
*/
|
|
46
|
+
public static async drainPendingCacheOps(timeoutMs: number = 5_000): Promise<void> {
|
|
47
|
+
if (Entity.pendingCacheOps.size === 0) return;
|
|
48
|
+
const snapshot = [...Entity.pendingCacheOps];
|
|
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
|
+
* Await all pending post-commit side effects (cache invalidation +
|
|
61
|
+
* lifecycle hooks scheduled via queueMicrotask from save()). Call from
|
|
62
|
+
* test setup/teardown hooks under PGlite to guarantee prior-file
|
|
63
|
+
* background work has settled before the next file's saves run. Bounded
|
|
64
|
+
* by `timeoutMs`. Safe to call repeatedly; no-op when the set is empty.
|
|
65
|
+
*/
|
|
66
|
+
public static async drainPendingSideEffects(timeoutMs: number = 5_000): Promise<void> {
|
|
67
|
+
if (Entity.pendingSideEffects.size === 0) return;
|
|
68
|
+
const snapshot = [...Entity.pendingSideEffects];
|
|
69
|
+
const drainTimer = new Promise<'timeout'>((resolve) => {
|
|
70
|
+
const t = setTimeout(() => resolve('timeout'), timeoutMs);
|
|
71
|
+
t.unref?.();
|
|
72
|
+
});
|
|
73
|
+
await Promise.race([
|
|
74
|
+
Promise.allSettled(snapshot).then(() => 'drained' as const),
|
|
75
|
+
drainTimer,
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private static trackCacheOp(p: Promise<void>): void {
|
|
80
|
+
Entity.pendingCacheOps.add(p);
|
|
81
|
+
p.finally(() => Entity.pendingCacheOps.delete(p));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private static trackSideEffect(p: Promise<void>): void {
|
|
85
|
+
Entity.pendingSideEffects.add(p);
|
|
86
|
+
p.finally(() => Entity.pendingSideEffects.delete(p));
|
|
87
|
+
}
|
|
88
|
+
|
|
25
89
|
constructor(id?: string) {
|
|
26
90
|
// Use || instead of ?? to also handle empty strings
|
|
27
91
|
this.id = (id && id.trim() !== '') ? id : uuidv7();
|
|
@@ -89,15 +153,18 @@ export class Entity implements IEntity {
|
|
|
89
153
|
Object.assign(instance, {});
|
|
90
154
|
}
|
|
91
155
|
this.addComponent(instance);
|
|
92
|
-
this._dirty = true;
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
156
|
+
this._dirty = true;
|
|
157
|
+
// executeHooks is async; the surrounding try/catch only captures
|
|
158
|
+
// synchronous throws. Attach a .catch so an async rejection from a
|
|
159
|
+
// hook handler does not escape as an unhandled rejection (H-HOOK-1).
|
|
160
|
+
// Add stays sync to preserve the fluent chaining signature; hook
|
|
161
|
+
// failures are logged and do not fail the add operation.
|
|
162
|
+
Promise.resolve()
|
|
163
|
+
.then(() => EntityHookManager.executeHooks(new ComponentAddedEvent(this, instance)))
|
|
164
|
+
.catch((error) => {
|
|
165
|
+
logger.error(`Error firing component added hook for ${instance.getTypeID()}: ${error}`);
|
|
166
|
+
});
|
|
167
|
+
|
|
101
168
|
return this;
|
|
102
169
|
}
|
|
103
170
|
|
|
@@ -120,9 +187,11 @@ export class Entity implements IEntity {
|
|
|
120
187
|
component.setDirty(true);
|
|
121
188
|
this._dirty = true;
|
|
122
189
|
|
|
123
|
-
// Fire component updated event
|
|
190
|
+
// Fire component updated event. Await so a hook rejection is
|
|
191
|
+
// captured by this method's try/catch and does not escape as an
|
|
192
|
+
// unhandled rejection (H-HOOK-1).
|
|
124
193
|
try {
|
|
125
|
-
EntityHookManager.executeHooks(new ComponentUpdatedEvent(this, component, oldData, component));
|
|
194
|
+
await EntityHookManager.executeHooks(new ComponentUpdatedEvent(this, component, oldData, component));
|
|
126
195
|
} catch (error) {
|
|
127
196
|
logger.error(`Error firing component updated hook for ${component.getTypeID()}: ${error}`);
|
|
128
197
|
// Don't fail the set operation if hooks fail
|
|
@@ -136,26 +205,25 @@ export class Entity implements IEntity {
|
|
|
136
205
|
});
|
|
137
206
|
}
|
|
138
207
|
|
|
139
|
-
//
|
|
140
|
-
|
|
208
|
+
// Fire-and-forget cache update, tracked via drainable set so
|
|
209
|
+
// App.shutdown can await it (H-CACHE-1).
|
|
210
|
+
Entity.trackCacheOp((async () => {
|
|
141
211
|
try {
|
|
142
212
|
const { CacheManager } = await import('./cache/CacheManager');
|
|
143
213
|
const cacheManager = CacheManager.getInstance();
|
|
144
214
|
const config = cacheManager.getConfig();
|
|
145
|
-
|
|
215
|
+
|
|
146
216
|
if (config.enabled && config.component?.enabled) {
|
|
147
217
|
if (config.strategy === 'write-through') {
|
|
148
|
-
// Write-through: update cache with new component data
|
|
149
218
|
await cacheManager.setComponentWriteThrough(this.id, [component], component.getTypeID(), config.component.ttl);
|
|
150
219
|
} else {
|
|
151
|
-
// Write-invalidate: remove from cache
|
|
152
220
|
await cacheManager.invalidateComponent(this.id, component.getTypeID());
|
|
153
221
|
}
|
|
154
222
|
}
|
|
155
223
|
} catch (error) {
|
|
156
|
-
logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache operation failed after set', error });
|
|
224
|
+
logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache operation failed after set', err: error });
|
|
157
225
|
}
|
|
158
|
-
});
|
|
226
|
+
})());
|
|
159
227
|
} else {
|
|
160
228
|
// Add new component
|
|
161
229
|
this.add(ctor, data);
|
|
@@ -185,13 +253,14 @@ export class Entity implements IEntity {
|
|
|
185
253
|
this.components.delete(typeId);
|
|
186
254
|
this._dirty = true;
|
|
187
255
|
|
|
188
|
-
// Fire component removed event
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
256
|
+
// Fire component removed event. remove() stays sync to preserve
|
|
257
|
+
// the boolean return signature used by callers; attach .catch so
|
|
258
|
+
// async hook rejections do not escape (H-HOOK-1).
|
|
259
|
+
Promise.resolve()
|
|
260
|
+
.then(() => EntityHookManager.executeHooks(new ComponentRemovedEvent(this, component)))
|
|
261
|
+
.catch((error) => {
|
|
262
|
+
logger.error(`Error firing component removed hook for ${typeId}: ${error}`);
|
|
263
|
+
});
|
|
195
264
|
|
|
196
265
|
// Invalidate DataLoader cache if context is provided
|
|
197
266
|
if (context?.loaders?.componentsByEntityType) {
|
|
@@ -201,20 +270,21 @@ export class Entity implements IEntity {
|
|
|
201
270
|
});
|
|
202
271
|
}
|
|
203
272
|
|
|
204
|
-
//
|
|
205
|
-
|
|
273
|
+
// Fire-and-forget cache invalidation, tracked for shutdown drain
|
|
274
|
+
// (H-CACHE-1).
|
|
275
|
+
Entity.trackCacheOp((async () => {
|
|
206
276
|
try {
|
|
207
277
|
const { CacheManager } = await import('./cache/CacheManager');
|
|
208
278
|
const cacheManager = CacheManager.getInstance();
|
|
209
279
|
const config = cacheManager.getConfig();
|
|
210
|
-
|
|
280
|
+
|
|
211
281
|
if (config.enabled && config.component?.enabled) {
|
|
212
282
|
await cacheManager.invalidateComponent(this.id, typeId);
|
|
213
283
|
}
|
|
214
284
|
} catch (error) {
|
|
215
|
-
logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache invalidation failed after remove', error });
|
|
285
|
+
logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache invalidation failed after remove', err: error });
|
|
216
286
|
}
|
|
217
|
-
});
|
|
287
|
+
})());
|
|
218
288
|
|
|
219
289
|
return true;
|
|
220
290
|
}
|
|
@@ -314,6 +384,81 @@ export class Entity implements IEntity {
|
|
|
314
384
|
return this._loadComponent(ctor, context);
|
|
315
385
|
}
|
|
316
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Discard in-memory component state and re-hydrate from the database.
|
|
389
|
+
* Preserves entity identity — callers holding a reference see fresh data
|
|
390
|
+
* on the same instance. Use after a raw-SQL write that bypassed
|
|
391
|
+
* `entity.set`/`entity.save`, or when a different `Entity` instance with
|
|
392
|
+
* the same id mutated persisted data.
|
|
393
|
+
*
|
|
394
|
+
* @param opts Optional transaction
|
|
395
|
+
*/
|
|
396
|
+
public async reload(opts?: { trx?: SQL }): Promise<this> {
|
|
397
|
+
if (!this.id || this.id.trim() === '') {
|
|
398
|
+
return this;
|
|
399
|
+
}
|
|
400
|
+
this.components.clear();
|
|
401
|
+
this.removedComponents.clear();
|
|
402
|
+
this.savedRemovedComponents.clear();
|
|
403
|
+
|
|
404
|
+
const dbConn = opts?.trx ?? db;
|
|
405
|
+
const rows = await dbConn`
|
|
406
|
+
SELECT c.id, c.type_id, c.data
|
|
407
|
+
FROM components c
|
|
408
|
+
WHERE c.entity_id = ${this.id} AND c.deleted_at IS NULL
|
|
409
|
+
`;
|
|
410
|
+
|
|
411
|
+
const storage = getMetadataStorage();
|
|
412
|
+
for (const row of rows) {
|
|
413
|
+
const ctor = ComponentRegistry.getConstructor(row.type_id);
|
|
414
|
+
if (!ctor) continue;
|
|
415
|
+
const comp: any = new ctor();
|
|
416
|
+
const parsed = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
|
|
417
|
+
Object.assign(comp, parsed);
|
|
418
|
+
comp.id = row.id;
|
|
419
|
+
const props = storage.componentProperties.get(row.type_id);
|
|
420
|
+
if (props) {
|
|
421
|
+
for (const prop of props) {
|
|
422
|
+
if (prop.propertyType === Date && typeof comp[prop.propertyKey] === 'string') {
|
|
423
|
+
comp[prop.propertyKey] = new Date(comp[prop.propertyKey]);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
comp.setPersisted(true);
|
|
428
|
+
comp.setDirty(false);
|
|
429
|
+
this.addComponent(comp);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
this.setPersisted(true);
|
|
433
|
+
this.setDirty(false);
|
|
434
|
+
return this;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Ensure the given components are hydrated on this entity's in-memory
|
|
439
|
+
* componentList. No-op for components already loaded. Batched: one SQL
|
|
440
|
+
* call for all missing components.
|
|
441
|
+
*
|
|
442
|
+
* Required when a later `entity.set(...)` + `entity.save()` may trigger
|
|
443
|
+
* a `@ComponentTargetHook` whose `includeComponents` lists tag
|
|
444
|
+
* components. Hook matching reads `componentList()` (in-memory only), so
|
|
445
|
+
* tags must be loaded first for the hook to fire.
|
|
446
|
+
*
|
|
447
|
+
* @param ctors Component constructors to ensure are loaded
|
|
448
|
+
*/
|
|
449
|
+
public async requireComponents(ctors: Array<new (...args: any[]) => BaseComponent>): Promise<void> {
|
|
450
|
+
if (ctors.length === 0) return;
|
|
451
|
+
const missing: string[] = [];
|
|
452
|
+
for (const ctor of ctors) {
|
|
453
|
+
const existing = Array.from(this.components.values()).find(c => c instanceof ctor);
|
|
454
|
+
if (existing) continue;
|
|
455
|
+
const typeId = new ctor().getTypeID();
|
|
456
|
+
missing.push(typeId);
|
|
457
|
+
}
|
|
458
|
+
if (missing.length === 0) return;
|
|
459
|
+
await Entity.LoadComponents([this], missing);
|
|
460
|
+
}
|
|
461
|
+
|
|
317
462
|
private async _loadComponent<T extends BaseComponent>(ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL }): Promise<T | null> {
|
|
318
463
|
const comp = Array.from(this.components.values()).find(comp => comp instanceof ctor) as T | undefined;
|
|
319
464
|
if (typeof comp !== "undefined") {
|
|
@@ -383,46 +528,122 @@ export class Entity implements IEntity {
|
|
|
383
528
|
}
|
|
384
529
|
|
|
385
530
|
@timed("Entity.save")
|
|
386
|
-
public save(trx?: SQL, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL }) {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
531
|
+
public async save(trx?: SQL, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL }): Promise<boolean> {
|
|
532
|
+
// Capture pre-save state BEFORE doSave mutates persisted/dirty flags.
|
|
533
|
+
const wasNew = !this._persisted;
|
|
534
|
+
const changedComponentTypeIds = this.getDirtyComponents();
|
|
535
|
+
const removedComponentTypeIds = Array.from(this.removedComponents);
|
|
536
|
+
|
|
537
|
+
// Pre-flight: await ComponentRegistry readiness for every component on
|
|
538
|
+
// this entity BEFORE opening the transaction. Previously doSave awaited
|
|
539
|
+
// ComponentRegistry.getReadyPromise inside the executeSave loop, so a
|
|
540
|
+
// slow DDL (partition creation) would keep the PG transaction open and
|
|
541
|
+
// idle-in-transaction waiting on registry state. (H-DB-4).
|
|
542
|
+
for (const comp of this.components.values()) {
|
|
543
|
+
const compName = comp.constructor.name;
|
|
544
|
+
if (!ComponentRegistry.isComponentReady(compName)) {
|
|
545
|
+
await ComponentRegistry.getReadyPromise(compName);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const profile = process.env.DB_SAVE_PROFILE === 'true';
|
|
550
|
+
const phaseStart = profile ? performance.now() : 0;
|
|
551
|
+
const phases: Record<string, number> = {};
|
|
552
|
+
|
|
553
|
+
// AbortController cancels in-flight queries and propagates ROLLBACK
|
|
554
|
+
// when the wall-clock timer fires. Throwing from inside the transaction
|
|
555
|
+
// callback triggers Bun SQL's auto-ROLLBACK, releasing the pooled connection.
|
|
556
|
+
const controller = new AbortController();
|
|
557
|
+
const timeoutMs = QUERY_TIMEOUT_MS;
|
|
558
|
+
const timeoutHandle = setTimeout(() => {
|
|
559
|
+
const err = new Error(`Entity save timeout for entity ${this.id} after ${timeoutMs}ms`);
|
|
560
|
+
logger.error({ scope: 'Entity.save', entityId: this.id, timeoutMs }, err.message);
|
|
561
|
+
controller.abort(err);
|
|
562
|
+
}, timeoutMs);
|
|
397
563
|
|
|
564
|
+
try {
|
|
565
|
+
const dbStart = profile ? performance.now() : 0;
|
|
398
566
|
if (trx) {
|
|
399
|
-
|
|
400
|
-
this.doSave(trx)
|
|
401
|
-
.then(async result => {
|
|
402
|
-
clearTimeout(timeout);
|
|
403
|
-
await this.handleCacheAfterSave(changedComponentTypeIds, removedComponentTypeIds, context);
|
|
404
|
-
resolve(result);
|
|
405
|
-
})
|
|
406
|
-
.catch(error => {
|
|
407
|
-
clearTimeout(timeout);
|
|
408
|
-
reject(error);
|
|
409
|
-
});
|
|
567
|
+
await this.doSave(trx, controller.signal);
|
|
410
568
|
} else {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
return await this.doSave(newTrx);
|
|
414
|
-
})
|
|
415
|
-
.then(async result => {
|
|
416
|
-
clearTimeout(timeout);
|
|
417
|
-
await this.handleCacheAfterSave(changedComponentTypeIds, removedComponentTypeIds, context);
|
|
418
|
-
resolve(result);
|
|
419
|
-
})
|
|
420
|
-
.catch(error => {
|
|
421
|
-
clearTimeout(timeout);
|
|
422
|
-
reject(error);
|
|
569
|
+
await db.transaction(async (newTrx) => {
|
|
570
|
+
await this.doSave(newTrx, controller.signal);
|
|
423
571
|
});
|
|
424
572
|
}
|
|
425
|
-
|
|
573
|
+
if (profile) phases.db = performance.now() - dbStart;
|
|
574
|
+
|
|
575
|
+
clearTimeout(timeoutHandle);
|
|
576
|
+
|
|
577
|
+
// Post-commit side effects are fire-and-forget so Redis / hook
|
|
578
|
+
// latency cannot consume the save budget or block the caller.
|
|
579
|
+
// Tracked in pendingSideEffects so tests/shutdown can drain
|
|
580
|
+
// background work before asserting or tearing down.
|
|
581
|
+
const sideEffectPromise = new Promise<void>((resolve) => {
|
|
582
|
+
queueMicrotask(() => {
|
|
583
|
+
this.runPostCommitSideEffects(
|
|
584
|
+
wasNew,
|
|
585
|
+
changedComponentTypeIds,
|
|
586
|
+
removedComponentTypeIds,
|
|
587
|
+
context,
|
|
588
|
+
profile ? phases : undefined,
|
|
589
|
+
profile ? phaseStart : undefined,
|
|
590
|
+
).finally(() => resolve());
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
Entity.trackSideEffect(sideEffectPromise);
|
|
594
|
+
|
|
595
|
+
return true;
|
|
596
|
+
} catch (error) {
|
|
597
|
+
clearTimeout(timeoutHandle);
|
|
598
|
+
if (controller.signal.aborted) {
|
|
599
|
+
throw controller.signal.reason ?? error;
|
|
600
|
+
}
|
|
601
|
+
throw error;
|
|
602
|
+
} finally {
|
|
603
|
+
// Ensure AbortController listeners are released even on success.
|
|
604
|
+
if (!controller.signal.aborted) controller.abort();
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Fire-and-forget post-commit work: cache invalidation + lifecycle hooks.
|
|
610
|
+
* Runs outside the save budget. Errors are logged and swallowed so cache
|
|
611
|
+
* or hook failures never surface as save failures.
|
|
612
|
+
*/
|
|
613
|
+
private async runPostCommitSideEffects(
|
|
614
|
+
wasNew: boolean,
|
|
615
|
+
changedComponentTypeIds: string[],
|
|
616
|
+
removedComponentTypeIds: string[],
|
|
617
|
+
context: { loaders?: { componentsByEntityType?: any }; trx?: SQL } | undefined,
|
|
618
|
+
phases: Record<string, number> | undefined,
|
|
619
|
+
phaseStart: number | undefined,
|
|
620
|
+
): Promise<void> {
|
|
621
|
+
const profile = phases !== undefined && phaseStart !== undefined;
|
|
622
|
+
|
|
623
|
+
const cacheStart = profile ? performance.now() : 0;
|
|
624
|
+
try {
|
|
625
|
+
await this.handleCacheAfterSave(changedComponentTypeIds, removedComponentTypeIds, context);
|
|
626
|
+
} catch (err) {
|
|
627
|
+
logger.warn({ scope: 'cache', entityId: this.id, err }, 'post-commit cache invalidation failed');
|
|
628
|
+
}
|
|
629
|
+
if (profile) phases!.cache = performance.now() - cacheStart;
|
|
630
|
+
|
|
631
|
+
const hookStart = profile ? performance.now() : 0;
|
|
632
|
+
try {
|
|
633
|
+
if (wasNew) {
|
|
634
|
+
await EntityHookManager.executeHooks(new EntityCreatedEvent(this));
|
|
635
|
+
} else if (changedComponentTypeIds.length > 0) {
|
|
636
|
+
await EntityHookManager.executeHooks(new EntityUpdatedEvent(this, changedComponentTypeIds));
|
|
637
|
+
}
|
|
638
|
+
} catch (err) {
|
|
639
|
+
logger.error({ scope: 'hooks', entityId: this.id, err }, 'post-commit lifecycle hooks failed');
|
|
640
|
+
}
|
|
641
|
+
if (profile) phases!.hooks = performance.now() - hookStart;
|
|
642
|
+
|
|
643
|
+
if (profile) {
|
|
644
|
+
phases!.total = performance.now() - phaseStart!;
|
|
645
|
+
logger.info({ scope: 'Entity.save.profile', entityId: this.id, phases }, 'Entity.save phase timings');
|
|
646
|
+
}
|
|
426
647
|
}
|
|
427
648
|
|
|
428
649
|
/**
|
|
@@ -490,235 +711,283 @@ export class Entity implements IEntity {
|
|
|
490
711
|
}
|
|
491
712
|
}
|
|
492
713
|
|
|
493
|
-
public doSave(trx: SQL) {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
}
|
|
714
|
+
public async doSave(trx: SQL, signal?: AbortSignal): Promise<boolean> {
|
|
715
|
+
// Validate entity ID to prevent PostgreSQL UUID parsing errors
|
|
716
|
+
if (!this.id || this.id.trim() === '') {
|
|
717
|
+
logger.error(`Cannot save entity: id is empty or invalid`);
|
|
718
|
+
throw new Error(`Cannot save entity: id is empty or invalid`);
|
|
719
|
+
}
|
|
500
720
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
721
|
+
if (!this._dirty) {
|
|
722
|
+
let dirtyComponents: string[] = [];
|
|
723
|
+
try {
|
|
724
|
+
dirtyComponents = this.getDirtyComponents();
|
|
725
|
+
} catch {
|
|
726
|
+
// best-effort diagnostics only
|
|
727
|
+
}
|
|
508
728
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
729
|
+
const removedTypeIds = Array.from(this.removedComponents);
|
|
730
|
+
const entityType = (this as any)?.constructor?.name ?? "Entity";
|
|
731
|
+
const dirtyComponentPreview = dirtyComponents.slice(0, 10).map((component) => {
|
|
732
|
+
const anyComponent = component as any;
|
|
733
|
+
return {
|
|
734
|
+
type: anyComponent?.constructor?.name ?? "Component",
|
|
735
|
+
typeId: typeof anyComponent?.getTypeID === "function" ? anyComponent.getTypeID() : undefined,
|
|
736
|
+
id: anyComponent?.id,
|
|
737
|
+
persisted: anyComponent?._persisted,
|
|
738
|
+
dirty: anyComponent?._dirty,
|
|
739
|
+
};
|
|
740
|
+
});
|
|
521
741
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
},
|
|
536
|
-
removedComponents: {
|
|
537
|
-
count: removedTypeIds.length,
|
|
538
|
-
typeIdsPreview: removedTypeIds.slice(0, 10),
|
|
539
|
-
},
|
|
742
|
+
logger.trace(
|
|
743
|
+
{
|
|
744
|
+
component: "Entity",
|
|
745
|
+
entity: {
|
|
746
|
+
type: entityType,
|
|
747
|
+
id: this.id,
|
|
748
|
+
persisted: this._persisted,
|
|
749
|
+
dirty: this._dirty,
|
|
750
|
+
},
|
|
751
|
+
components: {
|
|
752
|
+
total: this.components.size,
|
|
753
|
+
dirtyCount: dirtyComponents.length,
|
|
754
|
+
dirtyPreview: dirtyComponentPreview,
|
|
540
755
|
},
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
756
|
+
removedComponents: {
|
|
757
|
+
count: removedTypeIds.length,
|
|
758
|
+
typeIdsPreview: removedTypeIds.slice(0, 10),
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
"[Entity.doSave] Skipping save because entity is not dirty"
|
|
762
|
+
);
|
|
763
|
+
return true;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Execute a Bun SQL query with AbortSignal support. On abort, the
|
|
767
|
+
// in-flight query is cancelled (SQL.Query.cancel()) which causes the
|
|
768
|
+
// transaction callback to throw, triggering Bun's automatic ROLLBACK
|
|
769
|
+
// and releasing the pooled backend connection. Without this, a wall-
|
|
770
|
+
// clock timeout leaks backends into `idle in transaction` state —
|
|
771
|
+
// fatal under pgbouncer transaction-mode pooling.
|
|
772
|
+
const run = async <T>(q: any): Promise<T> => {
|
|
773
|
+
if (!signal) return await q;
|
|
774
|
+
if (signal.aborted) {
|
|
775
|
+
try { q.cancel?.(); } catch { /* ignore */ }
|
|
776
|
+
throw signal.reason ?? new Error('Entity.save aborted');
|
|
777
|
+
}
|
|
778
|
+
const onAbort = () => { try { q.cancel?.(); } catch { /* ignore */ } };
|
|
779
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
780
|
+
try {
|
|
781
|
+
return await q;
|
|
782
|
+
} finally {
|
|
783
|
+
signal.removeEventListener('abort', onAbort);
|
|
544
784
|
}
|
|
785
|
+
};
|
|
545
786
|
|
|
546
|
-
|
|
547
|
-
|
|
787
|
+
const executeSave = async (saveTrx: SQL) => {
|
|
788
|
+
if (!this._persisted) {
|
|
789
|
+
await run(saveTrx`INSERT INTO entities (id) VALUES (${this.id}) ON CONFLICT DO NOTHING`);
|
|
790
|
+
this._persisted = true;
|
|
791
|
+
}
|
|
548
792
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
793
|
+
// Delete removed components from database
|
|
794
|
+
if (this.removedComponents.size > 0) {
|
|
795
|
+
const typeIds = Array.from(this.removedComponents);
|
|
796
|
+
await run(saveTrx`DELETE FROM components WHERE entity_id = ${this.id} AND type_id IN ${sql(typeIds)}`);
|
|
797
|
+
await run(saveTrx`DELETE FROM entity_components WHERE entity_id = ${this.id} AND type_id IN ${sql(typeIds)}`);
|
|
798
|
+
// Move to savedRemovedComponents so resolvers can still detect removed components
|
|
799
|
+
// This is needed because DataLoader may have stale cached data for this request
|
|
800
|
+
for (const typeId of typeIds) {
|
|
801
|
+
this.savedRemovedComponents.add(typeId);
|
|
553
802
|
}
|
|
803
|
+
this.removedComponents.clear();
|
|
804
|
+
}
|
|
554
805
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
806
|
+
if (this.components.size === 0) {
|
|
807
|
+
logger.trace(`No components to save for entity ${this.id}`);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Batch inserts and updates for better performance
|
|
812
|
+
const componentsToInsert = [];
|
|
813
|
+
const entityComponentsToInsert = [];
|
|
814
|
+
const componentsToUpdate = [];
|
|
815
|
+
|
|
816
|
+
for (const comp of this.components.values()) {
|
|
817
|
+
const compName = comp.constructor.name;
|
|
818
|
+
// Registry readiness is pre-flighted in save() before the
|
|
819
|
+
// transaction starts (H-DB-4). This assert catches a
|
|
820
|
+
// theoretical race if a caller skipped save() and jumped
|
|
821
|
+
// straight to doSave — we refuse to await inside the txn so
|
|
822
|
+
// a slow DDL cannot hold a pg session idle in transaction.
|
|
823
|
+
if (!ComponentRegistry.isComponentReady(compName)) {
|
|
824
|
+
throw new Error(`Component ${compName} not ready; call save() (not doSave) or await registry readiness before the transaction.`);
|
|
571
825
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
const componentsToUpdate = [];
|
|
577
|
-
|
|
578
|
-
for(const comp of this.components.values()) {
|
|
579
|
-
const compName = comp.constructor.name;
|
|
580
|
-
if (!ComponentRegistry.isComponentReady(compName)) {
|
|
581
|
-
await ComponentRegistry.getReadyPromise(compName);
|
|
826
|
+
|
|
827
|
+
if (!(comp as any)._persisted) {
|
|
828
|
+
if (comp.id === "") {
|
|
829
|
+
comp.id = uuidv7();
|
|
582
830
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
831
|
+
componentsToInsert.push({
|
|
832
|
+
id: comp.id,
|
|
833
|
+
entity_id: this.id,
|
|
834
|
+
name: compName,
|
|
835
|
+
type_id: comp.getTypeID(),
|
|
836
|
+
data: comp.serializableData()
|
|
837
|
+
});
|
|
838
|
+
entityComponentsToInsert.push({
|
|
839
|
+
entity_id: this.id,
|
|
840
|
+
type_id: comp.getTypeID(),
|
|
841
|
+
component_id: comp.id
|
|
842
|
+
});
|
|
843
|
+
(comp as any).setPersisted(true);
|
|
844
|
+
(comp as any).setDirty(false);
|
|
845
|
+
} else if ((comp as any)._dirty) {
|
|
846
|
+
componentsToUpdate.push({
|
|
847
|
+
id: comp.id,
|
|
848
|
+
data: comp.serializableData()
|
|
849
|
+
});
|
|
850
|
+
(comp as any).setDirty(false);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Perform batch inserts
|
|
855
|
+
if (componentsToInsert.length > 0) {
|
|
856
|
+
await run(saveTrx`INSERT INTO components ${sql(componentsToInsert, 'id', 'entity_id', 'name', 'type_id', 'data')}`);
|
|
857
|
+
await run(saveTrx`INSERT INTO entity_components ${sql(entityComponentsToInsert, 'entity_id', 'type_id', 'component_id')} ON CONFLICT DO NOTHING`);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Insert entity_components for existing components if entity is new
|
|
861
|
+
if (!this._persisted) {
|
|
862
|
+
const existingEntityComponents = [];
|
|
863
|
+
for (const comp of this.components.values()) {
|
|
864
|
+
if ((comp as any)._persisted) {
|
|
865
|
+
existingEntityComponents.push({
|
|
596
866
|
entity_id: this.id,
|
|
597
867
|
type_id: comp.getTypeID(),
|
|
598
868
|
component_id: comp.id
|
|
599
869
|
});
|
|
600
|
-
(comp as any).setPersisted(true);
|
|
601
|
-
(comp as any).setDirty(false);
|
|
602
|
-
} else if((comp as any)._dirty) {
|
|
603
|
-
componentsToUpdate.push({
|
|
604
|
-
id: comp.id,
|
|
605
|
-
data: comp.serializableData()
|
|
606
|
-
});
|
|
607
|
-
(comp as any).setDirty(false);
|
|
608
870
|
}
|
|
609
871
|
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
if(componentsToInsert.length > 0) {
|
|
613
|
-
await saveTrx`INSERT INTO components ${sql(componentsToInsert, 'id', 'entity_id', 'name', 'type_id', 'data')}`;
|
|
614
|
-
await saveTrx`INSERT INTO entity_components ${sql(entityComponentsToInsert, 'entity_id', 'type_id', 'component_id')} ON CONFLICT DO NOTHING`;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
// Insert entity_components for existing components if entity is new
|
|
618
|
-
if(!this._persisted) {
|
|
619
|
-
const existingEntityComponents = [];
|
|
620
|
-
for(const comp of this.components.values()) {
|
|
621
|
-
if((comp as any)._persisted) {
|
|
622
|
-
existingEntityComponents.push({
|
|
623
|
-
entity_id: this.id,
|
|
624
|
-
type_id: comp.getTypeID(),
|
|
625
|
-
component_id: comp.id
|
|
626
|
-
});
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
if(existingEntityComponents.length > 0) {
|
|
630
|
-
await saveTrx`INSERT INTO entity_components ${sql(existingEntityComponents, 'entity_id', 'type_id', 'component_id')} ON CONFLICT DO NOTHING`;
|
|
631
|
-
}
|
|
872
|
+
if (existingEntityComponents.length > 0) {
|
|
873
|
+
await run(saveTrx`INSERT INTO entity_components ${sql(existingEntityComponents, 'entity_id', 'type_id', 'component_id')} ON CONFLICT DO NOTHING`);
|
|
632
874
|
}
|
|
875
|
+
}
|
|
633
876
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
}
|
|
642
|
-
logger.trace({ componentId: comp.id, data: comp.data }, `[Entity.doSave] Updating component`);
|
|
643
|
-
await saveTrx`UPDATE components SET data = ${comp.data} WHERE id = ${comp.id}`;
|
|
877
|
+
// Perform batch updates
|
|
878
|
+
if (componentsToUpdate.length > 0) {
|
|
879
|
+
for (const comp of componentsToUpdate) {
|
|
880
|
+
// Validate component ID to prevent PostgreSQL UUID parsing errors
|
|
881
|
+
if (!comp.id || comp.id.trim() === '') {
|
|
882
|
+
logger.error(`Cannot update component: id is empty or invalid. Component data: ${JSON.stringify(comp.data).substring(0, 200)}`);
|
|
883
|
+
throw new Error(`Cannot update component: component id is empty or invalid`);
|
|
644
884
|
}
|
|
885
|
+
logger.trace({ componentId: comp.id, data: comp.data }, `[Entity.doSave] Updating component`);
|
|
886
|
+
await run(saveTrx`UPDATE components SET data = ${comp.data} WHERE id = ${comp.id}`);
|
|
645
887
|
}
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
await executeSave(trx);
|
|
888
|
+
}
|
|
889
|
+
};
|
|
649
890
|
|
|
650
|
-
|
|
891
|
+
await executeSave(trx);
|
|
651
892
|
|
|
652
|
-
|
|
653
|
-
try {
|
|
654
|
-
if (wasNew) {
|
|
655
|
-
await EntityHookManager.executeHooks(new EntityCreatedEvent(this));
|
|
656
|
-
} else if (changedComponents.length > 0) {
|
|
657
|
-
await EntityHookManager.executeHooks(new EntityUpdatedEvent(this, changedComponents));
|
|
658
|
-
}
|
|
659
|
-
} catch (error) {
|
|
660
|
-
logger.error(`Error firing lifecycle hooks for entity ${this.id}: ${error}`);
|
|
661
|
-
// Don't fail the save operation if hooks fail
|
|
662
|
-
}
|
|
893
|
+
this._dirty = false;
|
|
663
894
|
|
|
664
|
-
|
|
665
|
-
})
|
|
666
|
-
|
|
895
|
+
return true;
|
|
667
896
|
}
|
|
668
897
|
|
|
669
898
|
public delete(force: boolean = false) {
|
|
670
899
|
return EntityManager.deleteEntity(this, force);
|
|
671
900
|
}
|
|
672
901
|
|
|
673
|
-
public doDelete(force: boolean = false) {
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
902
|
+
public async doDelete(force: boolean = false): Promise<boolean> {
|
|
903
|
+
if (!this._persisted) {
|
|
904
|
+
logger.warn("Entity is not persisted, cannot delete.");
|
|
905
|
+
return false;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// AbortController cancels in-flight queries on wall-clock timeout so a
|
|
909
|
+
// hanging DELETE cannot leak backends into `idle in transaction` under
|
|
910
|
+
// pgbouncer transaction pool mode. Same pattern as Entity.save.
|
|
911
|
+
const controller = new AbortController();
|
|
912
|
+
const timeoutMs = QUERY_TIMEOUT_MS;
|
|
913
|
+
const timeoutHandle = setTimeout(() => {
|
|
914
|
+
const err = new Error(`Entity delete timeout for entity ${this.id} after ${timeoutMs}ms`);
|
|
915
|
+
logger.error({ scope: 'Entity.doDelete', entityId: this.id, timeoutMs }, err.message);
|
|
916
|
+
controller.abort(err);
|
|
917
|
+
}, timeoutMs);
|
|
918
|
+
|
|
919
|
+
const signal = controller.signal;
|
|
920
|
+
const run = async <T>(q: any): Promise<T> => {
|
|
921
|
+
if (signal.aborted) {
|
|
922
|
+
try { q.cancel?.(); } catch { /* ignore */ }
|
|
923
|
+
throw signal.reason ?? new Error('Entity.doDelete aborted');
|
|
678
924
|
}
|
|
925
|
+
const onAbort = () => { try { q.cancel?.(); } catch { /* ignore */ } };
|
|
926
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
679
927
|
try {
|
|
680
|
-
await
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
} else {
|
|
686
|
-
await trx`UPDATE entities SET deleted_at = CURRENT_TIMESTAMP WHERE id = ${this.id} AND deleted_at IS NULL`;
|
|
687
|
-
await trx`UPDATE entity_components SET deleted_at = CURRENT_TIMESTAMP WHERE entity_id = ${this.id} AND deleted_at IS NULL`;
|
|
688
|
-
await trx`UPDATE components SET deleted_at = CURRENT_TIMESTAMP WHERE entity_id = ${this.id} AND deleted_at IS NULL`;
|
|
689
|
-
}
|
|
690
|
-
});
|
|
928
|
+
return await q;
|
|
929
|
+
} finally {
|
|
930
|
+
signal.removeEventListener('abort', onAbort);
|
|
931
|
+
}
|
|
932
|
+
};
|
|
691
933
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
934
|
+
try {
|
|
935
|
+
await db.transaction(async (trx) => {
|
|
936
|
+
if (force) {
|
|
937
|
+
await run(trx`DELETE FROM entity_components WHERE entity_id = ${this.id}`);
|
|
938
|
+
await run(trx`DELETE FROM components WHERE entity_id = ${this.id}`);
|
|
939
|
+
await run(trx`DELETE FROM entities WHERE id = ${this.id}`);
|
|
940
|
+
} else {
|
|
941
|
+
await run(trx`UPDATE entities SET deleted_at = CURRENT_TIMESTAMP WHERE id = ${this.id} AND deleted_at IS NULL`);
|
|
942
|
+
await run(trx`UPDATE entity_components SET deleted_at = CURRENT_TIMESTAMP WHERE entity_id = ${this.id} AND deleted_at IS NULL`);
|
|
943
|
+
await run(trx`UPDATE components SET deleted_at = CURRENT_TIMESTAMP WHERE entity_id = ${this.id} AND deleted_at IS NULL`);
|
|
698
944
|
}
|
|
945
|
+
});
|
|
946
|
+
clearTimeout(timeoutHandle);
|
|
699
947
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
const cacheManager = CacheManager.getInstance();
|
|
704
|
-
const config = cacheManager.getConfig();
|
|
948
|
+
// Fire-and-forget post-commit side effects: lifecycle hooks + cache
|
|
949
|
+
// invalidation. Errors are logged, never propagate to caller.
|
|
950
|
+
queueMicrotask(() => this.runPostDeleteSideEffects(!force));
|
|
705
951
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
|
|
952
|
+
return true;
|
|
953
|
+
} catch (error) {
|
|
954
|
+
clearTimeout(timeoutHandle);
|
|
955
|
+
if (signal.aborted) {
|
|
956
|
+
logger.error({ scope: 'Entity.doDelete', entityId: this.id }, `Entity delete aborted: ${signal.reason ?? error}`);
|
|
957
|
+
} else {
|
|
958
|
+
logger.error({ scope: 'Entity.doDelete', entityId: this.id, err: error }, 'Failed to delete entity');
|
|
959
|
+
}
|
|
960
|
+
// Re-throw so callers can distinguish DB failures (pool exhausted,
|
|
961
|
+
// lock timeout, etc.) from "entity not found" / not persisted,
|
|
962
|
+
// which still returns `false`. Previously any error produced the
|
|
963
|
+
// same `false` return, hiding infrastructure problems (H-OBS-4).
|
|
964
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
965
|
+
} finally {
|
|
966
|
+
if (!signal.aborted) controller.abort();
|
|
967
|
+
}
|
|
968
|
+
}
|
|
715
969
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
970
|
+
private async runPostDeleteSideEffects(softDelete: boolean): Promise<void> {
|
|
971
|
+
try {
|
|
972
|
+
await EntityHookManager.executeHooks(new EntityDeletedEvent(this, softDelete));
|
|
973
|
+
} catch (err) {
|
|
974
|
+
logger.error({ scope: 'hooks', entityId: this.id, err }, 'post-delete lifecycle hooks failed');
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
try {
|
|
978
|
+
const { CacheManager } = await import('./cache/CacheManager');
|
|
979
|
+
const cacheManager = CacheManager.getInstance();
|
|
980
|
+
const config = cacheManager.getConfig();
|
|
981
|
+
|
|
982
|
+
if (config.enabled && config.entity?.enabled) {
|
|
983
|
+
await cacheManager.invalidateEntity(this.id);
|
|
720
984
|
}
|
|
721
|
-
|
|
985
|
+
if (config.enabled && config.component?.enabled) {
|
|
986
|
+
await cacheManager.invalidateAllEntityComponents(this.id);
|
|
987
|
+
}
|
|
988
|
+
} catch (err) {
|
|
989
|
+
logger.warn({ scope: 'cache', entityId: this.id, err }, 'post-delete cache invalidation failed');
|
|
990
|
+
}
|
|
722
991
|
}
|
|
723
992
|
|
|
724
993
|
public setPersisted(persisted: boolean) {
|