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.
Files changed (47) hide show
  1. package/CHANGELOG.md +318 -0
  2. package/CLAUDE.md +20 -0
  3. package/config/cache.config.ts +12 -2
  4. package/core/App.ts +300 -69
  5. package/core/ApplicationLifecycle.ts +68 -4
  6. package/core/Entity.ts +525 -256
  7. package/core/EntityHookManager.ts +88 -21
  8. package/core/EntityManager.ts +12 -3
  9. package/core/Logger.ts +4 -0
  10. package/core/RequestContext.ts +4 -1
  11. package/core/SchedulerManager.ts +105 -22
  12. package/core/cache/CacheFactory.ts +3 -1
  13. package/core/cache/CacheManager.ts +72 -17
  14. package/core/cache/RedisCache.ts +38 -3
  15. package/core/components/BaseComponent.ts +12 -2
  16. package/core/decorators/EntityHooks.ts +24 -12
  17. package/core/middleware/RateLimit.ts +105 -0
  18. package/core/middleware/index.ts +1 -0
  19. package/core/remote/OutboxWorker.ts +42 -35
  20. package/core/scheduler/DistributedLock.ts +22 -7
  21. package/database/PreparedStatementCache.ts +5 -13
  22. package/gql/builders/ResolverBuilder.ts +4 -4
  23. package/gql/complexityLimit.ts +95 -0
  24. package/gql/index.ts +15 -3
  25. package/gql/visitors/ResolverGeneratorVisitor.ts +16 -2
  26. package/package.json +1 -1
  27. package/query/ComponentInclusionNode.ts +18 -11
  28. package/query/OrNode.ts +2 -4
  29. package/query/Query.ts +42 -31
  30. package/query/SqlIdentifier.ts +105 -0
  31. package/query/builders/FullTextSearchBuilder.ts +19 -6
  32. package/service/ServiceRegistry.ts +28 -9
  33. package/service/index.ts +4 -2
  34. package/storage/LocalStorageProvider.ts +12 -3
  35. package/storage/S3StorageProvider.ts +6 -6
  36. package/tests/e2e/http.test.ts +6 -2
  37. package/tests/integration/entity/Entity.saveTimeout.test.ts +110 -0
  38. package/tests/unit/cache/CacheManager.test.ts +20 -0
  39. package/tests/unit/entity/Entity.components.test.ts +73 -0
  40. package/tests/unit/entity/Entity.drainSideEffects.test.ts +51 -0
  41. package/tests/unit/entity/Entity.reload.test.ts +63 -0
  42. package/tests/unit/entity/Entity.requireComponents.test.ts +72 -0
  43. package/tests/unit/query/Query.emptyString.test.ts +69 -0
  44. package/tests/unit/query/Query.test.ts +6 -4
  45. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +95 -0
  46. package/tests/unit/storage/S3StorageProvider.test.ts +6 -10
  47. 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
- // 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
-
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
- // Handle cache operations for component update
140
- setImmediate(async () => {
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
- 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
- }
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
- // Invalidate cache for removed component
205
- setImmediate(async () => {
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
- 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);
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
- // 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
- });
567
+ await this.doSave(trx, controller.signal);
410
568
  } 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);
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
- 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
- }
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
- if(!this._dirty) {
502
- let dirtyComponents: string[] = [];
503
- try {
504
- dirtyComponents = this.getDirtyComponents();
505
- } catch {
506
- // best-effort diagnostics only
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
- 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
- });
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
- 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
- },
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
- "[Entity.doSave] Skipping save because entity is not dirty"
542
- );
543
- return resolve(true);
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
- const wasNew = !this._persisted;
547
- const changedComponents = this.getDirtyComponents();
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
- 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;
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
- // 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;
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
- // 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);
826
+
827
+ if (!(comp as any)._persisted) {
828
+ if (comp.id === "") {
829
+ comp.id = uuidv7();
582
830
  }
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({
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
- // 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
- }
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
- // 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}`;
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
- this._dirty = false;
891
+ await executeSave(trx);
651
892
 
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
- }
893
+ this._dirty = false;
663
894
 
664
- resolve(true);
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
- return new Promise<boolean>(async resolve => {
675
- if(!this._persisted) {
676
- logger.warn("Entity is not persisted, cannot delete.");
677
- return resolve(false);
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 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
- });
928
+ return await q;
929
+ } finally {
930
+ signal.removeEventListener('abort', onAbort);
931
+ }
932
+ };
691
933
 
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
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
- // Invalidate cache after successful deletion
701
- try {
702
- const { CacheManager } = await import('./cache/CacheManager');
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
- 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
- }
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
- resolve(true);
717
- } catch (error) {
718
- logger.error(`Failed to delete entity: ${error}`);
719
- resolve(false);
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) {