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/CHANGELOG.md +266 -0
- package/config/cache.config.ts +12 -2
- package/core/App.ts +296 -69
- package/core/ApplicationLifecycle.ts +68 -4
- package/core/Entity.ts +407 -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 +92 -9
- package/core/cache/CacheFactory.ts +3 -1
- package/core/cache/CacheManager.ts +54 -17
- package/core/cache/RedisCache.ts +38 -3
- 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/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 +13 -6
- package/query/OrNode.ts +2 -4
- package/query/Query.ts +30 -3
- package/query/SqlIdentifier.ts +105 -0
- package/query/builders/FullTextSearchBuilder.ts +19 -6
- package/service/ServiceRegistry.ts +21 -8
- 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/storage/S3StorageProvider.test.ts +6 -10
- package/upload/FileValidator.ts +9 -6
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
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
//
|
|
140
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
//
|
|
205
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
547
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
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);
|
|
708
|
+
|
|
709
|
+
if (!(comp as any)._persisted) {
|
|
710
|
+
if (comp.id === "") {
|
|
711
|
+
comp.id = uuidv7();
|
|
582
712
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`;
|
|
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
|
-
|
|
773
|
+
await executeSave(trx);
|
|
649
774
|
|
|
650
|
-
|
|
775
|
+
this._dirty = false;
|
|
651
776
|
|
|
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
|
-
}
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
|
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
|
-
});
|
|
810
|
+
return await q;
|
|
811
|
+
} finally {
|
|
812
|
+
signal.removeEventListener('abort', onAbort);
|
|
813
|
+
}
|
|
814
|
+
};
|
|
691
815
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
}
|
|
713
|
-
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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) {
|