bunsane 0.2.10 → 0.3.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/core/Entity.ts CHANGED
@@ -22,6 +22,34 @@ 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
+ /**
31
+ * Await all pending background cache operations. Call during shutdown
32
+ * after HTTP drain but before cache.disconnect so setImmediate'd cache
33
+ * writes are not lost. Bounded by `timeoutMs`.
34
+ */
35
+ public static async drainPendingCacheOps(timeoutMs: number = 5_000): Promise<void> {
36
+ if (Entity.pendingCacheOps.size === 0) return;
37
+ const snapshot = [...Entity.pendingCacheOps];
38
+ const drainTimer = new Promise<'timeout'>((resolve) => {
39
+ const t = setTimeout(() => resolve('timeout'), timeoutMs);
40
+ t.unref?.();
41
+ });
42
+ await Promise.race([
43
+ Promise.allSettled(snapshot).then(() => 'drained' as const),
44
+ drainTimer,
45
+ ]);
46
+ }
47
+
48
+ private static trackCacheOp(p: Promise<void>): void {
49
+ Entity.pendingCacheOps.add(p);
50
+ p.finally(() => Entity.pendingCacheOps.delete(p));
51
+ }
52
+
25
53
  constructor(id?: string) {
26
54
  // Use || instead of ?? to also handle empty strings
27
55
  this.id = (id && id.trim() !== '') ? id : uuidv7();
@@ -89,15 +117,18 @@ export class Entity implements IEntity {
89
117
  Object.assign(instance, {});
90
118
  }
91
119
  this.addComponent(instance);
92
- this._dirty = true;
93
- // Fire component added event
94
- try {
95
- EntityHookManager.executeHooks(new ComponentAddedEvent(this, instance));
96
- } catch (error) {
97
- logger.error(`Error firing component added hook for ${instance.getTypeID()}: ${error}`);
98
- // Don't fail the add operation if hooks fail
99
- }
100
-
120
+ this._dirty = true;
121
+ // executeHooks is async; the surrounding try/catch only captures
122
+ // synchronous throws. Attach a .catch so an async rejection from a
123
+ // hook handler does not escape as an unhandled rejection (H-HOOK-1).
124
+ // Add stays sync to preserve the fluent chaining signature; hook
125
+ // failures are logged and do not fail the add operation.
126
+ Promise.resolve()
127
+ .then(() => EntityHookManager.executeHooks(new ComponentAddedEvent(this, instance)))
128
+ .catch((error) => {
129
+ logger.error(`Error firing component added hook for ${instance.getTypeID()}: ${error}`);
130
+ });
131
+
101
132
  return this;
102
133
  }
103
134
 
@@ -120,9 +151,11 @@ export class Entity implements IEntity {
120
151
  component.setDirty(true);
121
152
  this._dirty = true;
122
153
 
123
- // Fire component updated event
154
+ // Fire component updated event. Await so a hook rejection is
155
+ // captured by this method's try/catch and does not escape as an
156
+ // unhandled rejection (H-HOOK-1).
124
157
  try {
125
- EntityHookManager.executeHooks(new ComponentUpdatedEvent(this, component, oldData, component));
158
+ await EntityHookManager.executeHooks(new ComponentUpdatedEvent(this, component, oldData, component));
126
159
  } catch (error) {
127
160
  logger.error(`Error firing component updated hook for ${component.getTypeID()}: ${error}`);
128
161
  // Don't fail the set operation if hooks fail
@@ -136,26 +169,25 @@ export class Entity implements IEntity {
136
169
  });
137
170
  }
138
171
 
139
- // Handle cache operations for component update
140
- setImmediate(async () => {
172
+ // Fire-and-forget cache update, tracked via drainable set so
173
+ // App.shutdown can await it (H-CACHE-1).
174
+ Entity.trackCacheOp((async () => {
141
175
  try {
142
176
  const { CacheManager } = await import('./cache/CacheManager');
143
177
  const cacheManager = CacheManager.getInstance();
144
178
  const config = cacheManager.getConfig();
145
-
179
+
146
180
  if (config.enabled && config.component?.enabled) {
147
181
  if (config.strategy === 'write-through') {
148
- // Write-through: update cache with new component data
149
182
  await cacheManager.setComponentWriteThrough(this.id, [component], component.getTypeID(), config.component.ttl);
150
183
  } else {
151
- // Write-invalidate: remove from cache
152
184
  await cacheManager.invalidateComponent(this.id, component.getTypeID());
153
185
  }
154
186
  }
155
187
  } catch (error) {
156
- logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache operation failed after set', error });
188
+ logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache operation failed after set', err: error });
157
189
  }
158
- });
190
+ })());
159
191
  } else {
160
192
  // Add new component
161
193
  this.add(ctor, data);
@@ -185,13 +217,14 @@ export class Entity implements IEntity {
185
217
  this.components.delete(typeId);
186
218
  this._dirty = true;
187
219
 
188
- // Fire component removed event
189
- try {
190
- EntityHookManager.executeHooks(new ComponentRemovedEvent(this, component));
191
- } catch (error) {
192
- logger.error(`Error firing component removed hook for ${typeId}: ${error}`);
193
- // Don't fail the remove operation if hooks fail
194
- }
220
+ // Fire component removed event. remove() stays sync to preserve
221
+ // the boolean return signature used by callers; attach .catch so
222
+ // async hook rejections do not escape (H-HOOK-1).
223
+ Promise.resolve()
224
+ .then(() => EntityHookManager.executeHooks(new ComponentRemovedEvent(this, component)))
225
+ .catch((error) => {
226
+ logger.error(`Error firing component removed hook for ${typeId}: ${error}`);
227
+ });
195
228
 
196
229
  // Invalidate DataLoader cache if context is provided
197
230
  if (context?.loaders?.componentsByEntityType) {
@@ -201,20 +234,21 @@ export class Entity implements IEntity {
201
234
  });
202
235
  }
203
236
 
204
- // Invalidate cache for removed component
205
- setImmediate(async () => {
237
+ // Fire-and-forget cache invalidation, tracked for shutdown drain
238
+ // (H-CACHE-1).
239
+ Entity.trackCacheOp((async () => {
206
240
  try {
207
241
  const { CacheManager } = await import('./cache/CacheManager');
208
242
  const cacheManager = CacheManager.getInstance();
209
243
  const config = cacheManager.getConfig();
210
-
244
+
211
245
  if (config.enabled && config.component?.enabled) {
212
246
  await cacheManager.invalidateComponent(this.id, typeId);
213
247
  }
214
248
  } catch (error) {
215
- logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache invalidation failed after remove', error });
249
+ logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache invalidation failed after remove', err: error });
216
250
  }
217
- });
251
+ })());
218
252
 
219
253
  return true;
220
254
  }
@@ -383,46 +417,115 @@ export class Entity implements IEntity {
383
417
  }
384
418
 
385
419
  @timed("Entity.save")
386
- public save(trx?: SQL, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL }) {
387
- return new Promise<boolean>((resolve, reject) => {
388
- // Add timeout to prevent hanging
389
- const timeout = setTimeout(() => {
390
- logger.error(`Entity save timeout for entity ${this.id}`);
391
- reject(new Error(`Entity save timeout for entity ${this.id}`));
392
- }, QUERY_TIMEOUT_MS); // Configurable timeout via DB_QUERY_TIMEOUT env var
393
-
394
- // Capture dirty components BEFORE doSave clears the dirty flags
395
- const changedComponentTypeIds = this.getDirtyComponents();
396
- const removedComponentTypeIds = Array.from(this.removedComponents);
420
+ public async save(trx?: SQL, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL }): Promise<boolean> {
421
+ // Capture pre-save state BEFORE doSave mutates persisted/dirty flags.
422
+ const wasNew = !this._persisted;
423
+ const changedComponentTypeIds = this.getDirtyComponents();
424
+ const removedComponentTypeIds = Array.from(this.removedComponents);
425
+
426
+ // Pre-flight: await ComponentRegistry readiness for every component on
427
+ // this entity BEFORE opening the transaction. Previously doSave awaited
428
+ // ComponentRegistry.getReadyPromise inside the executeSave loop, so a
429
+ // slow DDL (partition creation) would keep the PG transaction open and
430
+ // idle-in-transaction waiting on registry state. (H-DB-4).
431
+ for (const comp of this.components.values()) {
432
+ const compName = comp.constructor.name;
433
+ if (!ComponentRegistry.isComponentReady(compName)) {
434
+ await ComponentRegistry.getReadyPromise(compName);
435
+ }
436
+ }
437
+
438
+ const profile = process.env.DB_SAVE_PROFILE === 'true';
439
+ const phaseStart = profile ? performance.now() : 0;
440
+ const phases: Record<string, number> = {};
441
+
442
+ // AbortController cancels in-flight queries and propagates ROLLBACK
443
+ // when the wall-clock timer fires. Throwing from inside the transaction
444
+ // callback triggers Bun SQL's auto-ROLLBACK, releasing the pooled connection.
445
+ const controller = new AbortController();
446
+ const timeoutMs = QUERY_TIMEOUT_MS;
447
+ const timeoutHandle = setTimeout(() => {
448
+ const err = new Error(`Entity save timeout for entity ${this.id} after ${timeoutMs}ms`);
449
+ logger.error({ scope: 'Entity.save', entityId: this.id, timeoutMs }, err.message);
450
+ controller.abort(err);
451
+ }, timeoutMs);
397
452
 
453
+ try {
454
+ const dbStart = profile ? performance.now() : 0;
398
455
  if (trx) {
399
- // Use provided transaction
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
- });
456
+ await this.doSave(trx, controller.signal);
410
457
  } else {
411
- // Create new transaction
412
- db.transaction(async (newTrx) => {
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);
458
+ await db.transaction(async (newTrx) => {
459
+ await this.doSave(newTrx, controller.signal);
423
460
  });
424
461
  }
425
- });
462
+ if (profile) phases.db = performance.now() - dbStart;
463
+
464
+ clearTimeout(timeoutHandle);
465
+
466
+ // Post-commit side effects are fire-and-forget so Redis / hook
467
+ // latency cannot consume the save budget or block the caller.
468
+ queueMicrotask(() => this.runPostCommitSideEffects(
469
+ wasNew,
470
+ changedComponentTypeIds,
471
+ removedComponentTypeIds,
472
+ context,
473
+ profile ? phases : undefined,
474
+ profile ? phaseStart : undefined,
475
+ ));
476
+
477
+ return true;
478
+ } catch (error) {
479
+ clearTimeout(timeoutHandle);
480
+ if (controller.signal.aborted) {
481
+ throw controller.signal.reason ?? error;
482
+ }
483
+ throw error;
484
+ } finally {
485
+ // Ensure AbortController listeners are released even on success.
486
+ if (!controller.signal.aborted) controller.abort();
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Fire-and-forget post-commit work: cache invalidation + lifecycle hooks.
492
+ * Runs outside the save budget. Errors are logged and swallowed so cache
493
+ * or hook failures never surface as save failures.
494
+ */
495
+ private async runPostCommitSideEffects(
496
+ wasNew: boolean,
497
+ changedComponentTypeIds: string[],
498
+ removedComponentTypeIds: string[],
499
+ context: { loaders?: { componentsByEntityType?: any }; trx?: SQL } | undefined,
500
+ phases: Record<string, number> | undefined,
501
+ phaseStart: number | undefined,
502
+ ): Promise<void> {
503
+ const profile = phases !== undefined && phaseStart !== undefined;
504
+
505
+ const cacheStart = profile ? performance.now() : 0;
506
+ try {
507
+ await this.handleCacheAfterSave(changedComponentTypeIds, removedComponentTypeIds, context);
508
+ } catch (err) {
509
+ logger.warn({ scope: 'cache', entityId: this.id, err }, 'post-commit cache invalidation failed');
510
+ }
511
+ if (profile) phases!.cache = performance.now() - cacheStart;
512
+
513
+ const hookStart = profile ? performance.now() : 0;
514
+ try {
515
+ if (wasNew) {
516
+ await EntityHookManager.executeHooks(new EntityCreatedEvent(this));
517
+ } else if (changedComponentTypeIds.length > 0) {
518
+ await EntityHookManager.executeHooks(new EntityUpdatedEvent(this, changedComponentTypeIds));
519
+ }
520
+ } catch (err) {
521
+ logger.error({ scope: 'hooks', entityId: this.id, err }, 'post-commit lifecycle hooks failed');
522
+ }
523
+ if (profile) phases!.hooks = performance.now() - hookStart;
524
+
525
+ if (profile) {
526
+ phases!.total = performance.now() - phaseStart!;
527
+ logger.info({ scope: 'Entity.save.profile', entityId: this.id, phases }, 'Entity.save phase timings');
528
+ }
426
529
  }
427
530
 
428
531
  /**
@@ -490,235 +593,283 @@ export class Entity implements IEntity {
490
593
  }
491
594
  }
492
595
 
493
- public doSave(trx: SQL) {
494
- return new Promise<boolean>(async (resolve, reject) => {
495
- // Validate entity ID to prevent PostgreSQL UUID parsing errors
496
- if (!this.id || this.id.trim() === '') {
497
- logger.error(`Cannot save entity: id is empty or invalid`);
498
- return reject(new Error(`Cannot save entity: id is empty or invalid`));
499
- }
596
+ public async doSave(trx: SQL, signal?: AbortSignal): Promise<boolean> {
597
+ // Validate entity ID to prevent PostgreSQL UUID parsing errors
598
+ if (!this.id || this.id.trim() === '') {
599
+ logger.error(`Cannot save entity: id is empty or invalid`);
600
+ throw new Error(`Cannot save entity: id is empty or invalid`);
601
+ }
500
602
 
501
- if(!this._dirty) {
502
- let dirtyComponents: string[] = [];
503
- try {
504
- dirtyComponents = this.getDirtyComponents();
505
- } catch {
506
- // best-effort diagnostics only
507
- }
603
+ if (!this._dirty) {
604
+ let dirtyComponents: string[] = [];
605
+ try {
606
+ dirtyComponents = this.getDirtyComponents();
607
+ } catch {
608
+ // best-effort diagnostics only
609
+ }
508
610
 
509
- const removedTypeIds = Array.from(this.removedComponents);
510
- const entityType = (this as any)?.constructor?.name ?? "Entity";
511
- const dirtyComponentPreview = dirtyComponents.slice(0, 10).map((component) => {
512
- const anyComponent = component as any;
513
- return {
514
- type: anyComponent?.constructor?.name ?? "Component",
515
- typeId: typeof anyComponent?.getTypeID === "function" ? anyComponent.getTypeID() : undefined,
516
- id: anyComponent?.id,
517
- persisted: anyComponent?._persisted,
518
- dirty: anyComponent?._dirty,
519
- };
520
- });
611
+ const removedTypeIds = Array.from(this.removedComponents);
612
+ const entityType = (this as any)?.constructor?.name ?? "Entity";
613
+ const dirtyComponentPreview = dirtyComponents.slice(0, 10).map((component) => {
614
+ const anyComponent = component as any;
615
+ return {
616
+ type: anyComponent?.constructor?.name ?? "Component",
617
+ typeId: typeof anyComponent?.getTypeID === "function" ? anyComponent.getTypeID() : undefined,
618
+ id: anyComponent?.id,
619
+ persisted: anyComponent?._persisted,
620
+ dirty: anyComponent?._dirty,
621
+ };
622
+ });
521
623
 
522
- logger.trace(
523
- {
524
- component: "Entity",
525
- entity: {
526
- type: entityType,
527
- id: this.id,
528
- persisted: this._persisted,
529
- dirty: this._dirty,
530
- },
531
- components: {
532
- total: this.components.size,
533
- dirtyCount: dirtyComponents.length,
534
- dirtyPreview: dirtyComponentPreview,
535
- },
536
- removedComponents: {
537
- count: removedTypeIds.length,
538
- typeIdsPreview: removedTypeIds.slice(0, 10),
539
- },
624
+ logger.trace(
625
+ {
626
+ component: "Entity",
627
+ entity: {
628
+ type: entityType,
629
+ id: this.id,
630
+ persisted: this._persisted,
631
+ dirty: this._dirty,
632
+ },
633
+ components: {
634
+ total: this.components.size,
635
+ dirtyCount: dirtyComponents.length,
636
+ dirtyPreview: dirtyComponentPreview,
540
637
  },
541
- "[Entity.doSave] Skipping save because entity is not dirty"
542
- );
543
- return resolve(true);
638
+ removedComponents: {
639
+ count: removedTypeIds.length,
640
+ typeIdsPreview: removedTypeIds.slice(0, 10),
641
+ },
642
+ },
643
+ "[Entity.doSave] Skipping save because entity is not dirty"
644
+ );
645
+ return true;
646
+ }
647
+
648
+ // Execute a Bun SQL query with AbortSignal support. On abort, the
649
+ // in-flight query is cancelled (SQL.Query.cancel()) which causes the
650
+ // transaction callback to throw, triggering Bun's automatic ROLLBACK
651
+ // and releasing the pooled backend connection. Without this, a wall-
652
+ // clock timeout leaks backends into `idle in transaction` state —
653
+ // fatal under pgbouncer transaction-mode pooling.
654
+ const run = async <T>(q: any): Promise<T> => {
655
+ if (!signal) return await q;
656
+ if (signal.aborted) {
657
+ try { q.cancel?.(); } catch { /* ignore */ }
658
+ throw signal.reason ?? new Error('Entity.save aborted');
659
+ }
660
+ const onAbort = () => { try { q.cancel?.(); } catch { /* ignore */ } };
661
+ signal.addEventListener('abort', onAbort, { once: true });
662
+ try {
663
+ return await q;
664
+ } finally {
665
+ signal.removeEventListener('abort', onAbort);
544
666
  }
667
+ };
545
668
 
546
- const wasNew = !this._persisted;
547
- const changedComponents = this.getDirtyComponents();
669
+ const executeSave = async (saveTrx: SQL) => {
670
+ if (!this._persisted) {
671
+ await run(saveTrx`INSERT INTO entities (id) VALUES (${this.id}) ON CONFLICT DO NOTHING`);
672
+ this._persisted = true;
673
+ }
548
674
 
549
- const executeSave = async (saveTrx: SQL) => {
550
- if(!this._persisted) {
551
- await saveTrx`INSERT INTO entities (id) VALUES (${this.id}) ON CONFLICT DO NOTHING`;
552
- this._persisted = true;
675
+ // Delete removed components from database
676
+ if (this.removedComponents.size > 0) {
677
+ const typeIds = Array.from(this.removedComponents);
678
+ await run(saveTrx`DELETE FROM components WHERE entity_id = ${this.id} AND type_id IN ${sql(typeIds)}`);
679
+ await run(saveTrx`DELETE FROM entity_components WHERE entity_id = ${this.id} AND type_id IN ${sql(typeIds)}`);
680
+ // Move to savedRemovedComponents so resolvers can still detect removed components
681
+ // This is needed because DataLoader may have stale cached data for this request
682
+ for (const typeId of typeIds) {
683
+ this.savedRemovedComponents.add(typeId);
553
684
  }
685
+ this.removedComponents.clear();
686
+ }
554
687
 
555
- // Delete removed components from database
556
- if (this.removedComponents.size > 0) {
557
- const typeIds = Array.from(this.removedComponents);
558
- await saveTrx`DELETE FROM components WHERE entity_id = ${this.id} AND type_id IN ${sql(typeIds)}`;
559
- await saveTrx`DELETE FROM entity_components WHERE entity_id = ${this.id} AND type_id IN ${sql(typeIds)}`;
560
- // Move to savedRemovedComponents so resolvers can still detect removed components
561
- // This is needed because DataLoader may have stale cached data for this request
562
- for (const typeId of typeIds) {
563
- this.savedRemovedComponents.add(typeId);
564
- }
565
- this.removedComponents.clear();
566
- }
567
-
568
- if(this.components.size === 0) {
569
- logger.trace(`No components to save for entity ${this.id}`);
570
- return;
688
+ if (this.components.size === 0) {
689
+ logger.trace(`No components to save for entity ${this.id}`);
690
+ return;
691
+ }
692
+
693
+ // Batch inserts and updates for better performance
694
+ const componentsToInsert = [];
695
+ const entityComponentsToInsert = [];
696
+ const componentsToUpdate = [];
697
+
698
+ for (const comp of this.components.values()) {
699
+ const compName = comp.constructor.name;
700
+ // Registry readiness is pre-flighted in save() before the
701
+ // transaction starts (H-DB-4). This assert catches a
702
+ // theoretical race if a caller skipped save() and jumped
703
+ // straight to doSave — we refuse to await inside the txn so
704
+ // a slow DDL cannot hold a pg session idle in transaction.
705
+ if (!ComponentRegistry.isComponentReady(compName)) {
706
+ throw new Error(`Component ${compName} not ready; call save() (not doSave) or await registry readiness before the transaction.`);
571
707
  }
572
-
573
- // Batch inserts and updates for better performance
574
- const componentsToInsert = [];
575
- const entityComponentsToInsert = [];
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);
708
+
709
+ if (!(comp as any)._persisted) {
710
+ if (comp.id === "") {
711
+ comp.id = uuidv7();
582
712
  }
583
-
584
- if(!(comp as any)._persisted) {
585
- if(comp.id === "") {
586
- comp.id = uuidv7();
587
- }
588
- componentsToInsert.push({
589
- id: comp.id,
590
- entity_id: this.id,
591
- name: compName,
592
- type_id: comp.getTypeID(),
593
- data: comp.serializableData()
594
- });
595
- entityComponentsToInsert.push({
713
+ componentsToInsert.push({
714
+ id: comp.id,
715
+ entity_id: this.id,
716
+ name: compName,
717
+ type_id: comp.getTypeID(),
718
+ data: comp.serializableData()
719
+ });
720
+ entityComponentsToInsert.push({
721
+ entity_id: this.id,
722
+ type_id: comp.getTypeID(),
723
+ component_id: comp.id
724
+ });
725
+ (comp as any).setPersisted(true);
726
+ (comp as any).setDirty(false);
727
+ } else if ((comp as any)._dirty) {
728
+ componentsToUpdate.push({
729
+ id: comp.id,
730
+ data: comp.serializableData()
731
+ });
732
+ (comp as any).setDirty(false);
733
+ }
734
+ }
735
+
736
+ // Perform batch inserts
737
+ if (componentsToInsert.length > 0) {
738
+ await run(saveTrx`INSERT INTO components ${sql(componentsToInsert, 'id', 'entity_id', 'name', 'type_id', 'data')}`);
739
+ await run(saveTrx`INSERT INTO entity_components ${sql(entityComponentsToInsert, 'entity_id', 'type_id', 'component_id')} ON CONFLICT DO NOTHING`);
740
+ }
741
+
742
+ // Insert entity_components for existing components if entity is new
743
+ if (!this._persisted) {
744
+ const existingEntityComponents = [];
745
+ for (const comp of this.components.values()) {
746
+ if ((comp as any)._persisted) {
747
+ existingEntityComponents.push({
596
748
  entity_id: this.id,
597
749
  type_id: comp.getTypeID(),
598
750
  component_id: comp.id
599
751
  });
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
752
  }
609
753
  }
610
-
611
- // Perform batch inserts
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
- }
754
+ if (existingEntityComponents.length > 0) {
755
+ await run(saveTrx`INSERT INTO entity_components ${sql(existingEntityComponents, 'entity_id', 'type_id', 'component_id')} ON CONFLICT DO NOTHING`);
632
756
  }
757
+ }
633
758
 
634
- // Perform batch updates
635
- if(componentsToUpdate.length > 0) {
636
- for(const comp of componentsToUpdate) {
637
- // Validate component ID to prevent PostgreSQL UUID parsing errors
638
- if (!comp.id || comp.id.trim() === '') {
639
- logger.error(`Cannot update component: id is empty or invalid. Component data: ${JSON.stringify(comp.data).substring(0, 200)}`);
640
- throw new Error(`Cannot update component: component id is empty or invalid`);
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}`;
759
+ // Perform batch updates
760
+ if (componentsToUpdate.length > 0) {
761
+ for (const comp of componentsToUpdate) {
762
+ // Validate component ID to prevent PostgreSQL UUID parsing errors
763
+ if (!comp.id || comp.id.trim() === '') {
764
+ logger.error(`Cannot update component: id is empty or invalid. Component data: ${JSON.stringify(comp.data).substring(0, 200)}`);
765
+ throw new Error(`Cannot update component: component id is empty or invalid`);
644
766
  }
767
+ logger.trace({ componentId: comp.id, data: comp.data }, `[Entity.doSave] Updating component`);
768
+ await run(saveTrx`UPDATE components SET data = ${comp.data} WHERE id = ${comp.id}`);
645
769
  }
646
- };
770
+ }
771
+ };
647
772
 
648
- await executeSave(trx);
773
+ await executeSave(trx);
649
774
 
650
- this._dirty = false;
775
+ this._dirty = false;
651
776
 
652
- // Fire lifecycle events after successful save
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
- }
663
-
664
- resolve(true);
665
- })
666
-
777
+ return true;
667
778
  }
668
779
 
669
780
  public delete(force: boolean = false) {
670
781
  return EntityManager.deleteEntity(this, force);
671
782
  }
672
783
 
673
- public doDelete(force: boolean = false) {
674
- return new Promise<boolean>(async resolve => {
675
- if(!this._persisted) {
676
- logger.warn("Entity is not persisted, cannot delete.");
677
- return resolve(false);
784
+ public async doDelete(force: boolean = false): Promise<boolean> {
785
+ if (!this._persisted) {
786
+ logger.warn("Entity is not persisted, cannot delete.");
787
+ return false;
788
+ }
789
+
790
+ // AbortController cancels in-flight queries on wall-clock timeout so a
791
+ // hanging DELETE cannot leak backends into `idle in transaction` under
792
+ // pgbouncer transaction pool mode. Same pattern as Entity.save.
793
+ const controller = new AbortController();
794
+ const timeoutMs = QUERY_TIMEOUT_MS;
795
+ const timeoutHandle = setTimeout(() => {
796
+ const err = new Error(`Entity delete timeout for entity ${this.id} after ${timeoutMs}ms`);
797
+ logger.error({ scope: 'Entity.doDelete', entityId: this.id, timeoutMs }, err.message);
798
+ controller.abort(err);
799
+ }, timeoutMs);
800
+
801
+ const signal = controller.signal;
802
+ const run = async <T>(q: any): Promise<T> => {
803
+ if (signal.aborted) {
804
+ try { q.cancel?.(); } catch { /* ignore */ }
805
+ throw signal.reason ?? new Error('Entity.doDelete aborted');
678
806
  }
807
+ const onAbort = () => { try { q.cancel?.(); } catch { /* ignore */ } };
808
+ signal.addEventListener('abort', onAbort, { once: true });
679
809
  try {
680
- await db.transaction(async (trx) => {
681
- if(force) {
682
- await trx`DELETE FROM entity_components WHERE entity_id = ${this.id}`;
683
- await trx`DELETE FROM components WHERE entity_id = ${this.id}`;
684
- await trx`DELETE FROM entities WHERE id = ${this.id}`;
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
- });
810
+ return await q;
811
+ } finally {
812
+ signal.removeEventListener('abort', onAbort);
813
+ }
814
+ };
691
815
 
692
- // Fire lifecycle event after successful deletion
693
- try {
694
- await EntityHookManager.executeHooks(new EntityDeletedEvent(this, !force));
695
- } catch (error) {
696
- logger.error(`Error firing delete lifecycle hook for entity ${this.id}: ${error}`);
697
- // Don't fail the delete operation if hooks fail
816
+ try {
817
+ await db.transaction(async (trx) => {
818
+ if (force) {
819
+ await run(trx`DELETE FROM entity_components WHERE entity_id = ${this.id}`);
820
+ await run(trx`DELETE FROM components WHERE entity_id = ${this.id}`);
821
+ await run(trx`DELETE FROM entities WHERE id = ${this.id}`);
822
+ } else {
823
+ await run(trx`UPDATE entities SET deleted_at = CURRENT_TIMESTAMP WHERE id = ${this.id} AND deleted_at IS NULL`);
824
+ await run(trx`UPDATE entity_components SET deleted_at = CURRENT_TIMESTAMP WHERE entity_id = ${this.id} AND deleted_at IS NULL`);
825
+ await run(trx`UPDATE components SET deleted_at = CURRENT_TIMESTAMP WHERE entity_id = ${this.id} AND deleted_at IS NULL`);
698
826
  }
827
+ });
828
+ clearTimeout(timeoutHandle);
699
829
 
700
- // Invalidate cache after successful deletion
701
- try {
702
- const { CacheManager } = await import('./cache/CacheManager');
703
- const cacheManager = CacheManager.getInstance();
704
- const config = cacheManager.getConfig();
830
+ // Fire-and-forget post-commit side effects: lifecycle hooks + cache
831
+ // invalidation. Errors are logged, never propagate to caller.
832
+ queueMicrotask(() => this.runPostDeleteSideEffects(!force));
705
833
 
706
- if (config.enabled && config.entity?.enabled) {
707
- await cacheManager.invalidateEntity(this.id);
708
- }
709
- if (config.enabled && config.component?.enabled) {
710
- await cacheManager.invalidateAllEntityComponents(this.id);
711
- }
712
- } catch (error) {
713
- logger.warn({ scope: 'cache', component: 'Entity', msg: 'Cache invalidation failed after delete', error });
714
- }
834
+ return true;
835
+ } catch (error) {
836
+ clearTimeout(timeoutHandle);
837
+ if (signal.aborted) {
838
+ logger.error({ scope: 'Entity.doDelete', entityId: this.id }, `Entity delete aborted: ${signal.reason ?? error}`);
839
+ } else {
840
+ logger.error({ scope: 'Entity.doDelete', entityId: this.id, err: error }, 'Failed to delete entity');
841
+ }
842
+ // Re-throw so callers can distinguish DB failures (pool exhausted,
843
+ // lock timeout, etc.) from "entity not found" / not persisted,
844
+ // which still returns `false`. Previously any error produced the
845
+ // same `false` return, hiding infrastructure problems (H-OBS-4).
846
+ throw error instanceof Error ? error : new Error(String(error));
847
+ } finally {
848
+ if (!signal.aborted) controller.abort();
849
+ }
850
+ }
715
851
 
716
- resolve(true);
717
- } catch (error) {
718
- logger.error(`Failed to delete entity: ${error}`);
719
- resolve(false);
852
+ private async runPostDeleteSideEffects(softDelete: boolean): Promise<void> {
853
+ try {
854
+ await EntityHookManager.executeHooks(new EntityDeletedEvent(this, softDelete));
855
+ } catch (err) {
856
+ logger.error({ scope: 'hooks', entityId: this.id, err }, 'post-delete lifecycle hooks failed');
857
+ }
858
+
859
+ try {
860
+ const { CacheManager } = await import('./cache/CacheManager');
861
+ const cacheManager = CacheManager.getInstance();
862
+ const config = cacheManager.getConfig();
863
+
864
+ if (config.enabled && config.entity?.enabled) {
865
+ await cacheManager.invalidateEntity(this.id);
866
+ }
867
+ if (config.enabled && config.component?.enabled) {
868
+ await cacheManager.invalidateAllEntityComponents(this.id);
720
869
  }
721
- })
870
+ } catch (err) {
871
+ logger.warn({ scope: 'cache', entityId: this.id, err }, 'post-delete cache invalidation failed');
872
+ }
722
873
  }
723
874
 
724
875
  public setPersisted(persisted: boolean) {