bunsane 0.3.0 → 0.3.2
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/.claude/scheduled_tasks.lock +1 -0
- package/CHANGELOG.md +104 -0
- package/CLAUDE.md +20 -0
- package/config/cache.config.ts +35 -1
- package/core/App.ts +24 -1060
- package/core/ArcheType.ts +78 -2110
- package/core/Entity.ts +136 -41
- package/core/RequestContext.ts +85 -36
- package/core/RequestLoaders.ts +89 -31
- package/core/SchedulerManager.ts +13 -13
- package/core/app/bootstrap.ts +133 -0
- package/core/app/cors.ts +94 -0
- package/core/app/graphqlSetup.ts +56 -0
- package/core/app/healthEndpoints.ts +31 -0
- package/core/app/metricsCollector.ts +27 -0
- package/core/app/preparedStatementWarmup.ts +55 -0
- package/core/app/processHandlers.ts +43 -0
- package/core/app/requestRouter.ts +309 -0
- package/core/app/restRegistry.ts +72 -0
- package/core/app/shutdown.ts +97 -0
- package/core/app/studioRouter.ts +83 -0
- package/core/archetype/customTypes.ts +100 -0
- package/core/archetype/decorators.ts +171 -0
- package/core/archetype/fieldResolvers.ts +621 -0
- package/core/archetype/helpers.ts +29 -0
- package/core/archetype/relationLoader.ts +118 -0
- package/core/archetype/schemaBuilder.ts +141 -0
- package/core/archetype/weaver.ts +218 -0
- package/core/archetype/zodSchemaBuilder.ts +527 -0
- package/core/cache/CacheManager.ts +144 -9
- package/core/components/BaseComponent.ts +12 -2
- package/core/middleware/AccessLog.ts +8 -1
- package/database/PreparedStatementCache.ts +17 -16
- package/database/cancellable.ts +22 -0
- package/database/instrumentedDb.ts +141 -0
- package/docs/RFC_APP_REFACTOR.md +248 -0
- package/docs/RFC_REFACTOR_TARGETS.md +251 -0
- package/package.json +1 -1
- package/query/ComponentInclusionNode.ts +5 -5
- package/query/Query.ts +65 -48
- package/service/ServiceRegistry.ts +7 -1
- package/service/index.ts +4 -2
- package/tests/integration/loaders/RequestLoaders.abort.test.ts +82 -0
- package/tests/integration/query/Query.abort.test.ts +66 -0
- package/tests/unit/cache/CacheManager.test.ts +152 -1
- package/tests/unit/database/cancellable.test.ts +81 -0
- package/tests/unit/database/instrumentedDb.test.ts +160 -0
- package/tests/unit/entity/Entity.components.test.ts +73 -0
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +51 -0
- package/tests/unit/entity/Entity.reload.test.ts +63 -0
- package/tests/unit/entity/Entity.requireComponents.test.ts +72 -0
- package/tests/unit/query/Query.emptyString.test.ts +69 -0
- package/tests/unit/query/Query.test.ts +6 -4
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +95 -0
package/core/Entity.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ComponentDataType, ComponentGetter, BaseComponent } from "./components";
|
|
2
2
|
import { logger } from "./Logger";
|
|
3
3
|
import db, { QUERY_TIMEOUT_MS } from "../database";
|
|
4
|
+
import { runWithSignal } from "../database/cancellable";
|
|
4
5
|
import EntityManager from "./EntityManager";
|
|
5
6
|
import ComponentRegistry from "./components/ComponentRegistry";
|
|
6
7
|
import { uuidv7 } from "../utils/uuid";
|
|
@@ -27,6 +28,17 @@ export class Entity implements IEntity {
|
|
|
27
28
|
// (H-CACHE-1).
|
|
28
29
|
private static pendingCacheOps: Set<Promise<void>> = new Set();
|
|
29
30
|
|
|
31
|
+
// Drainable set of post-commit side-effect Promises scheduled via
|
|
32
|
+
// queueMicrotask from save(). Includes cache invalidation + lifecycle
|
|
33
|
+
// hooks (EntityCreated / EntityUpdated). Hooks may transitively trigger
|
|
34
|
+
// more DB work (e.g., entity.save() from a handler), which is why this
|
|
35
|
+
// is tracked separately from pendingCacheOps. Tests running against
|
|
36
|
+
// PGlite's single-connection pool should drain this between test files
|
|
37
|
+
// to prevent background work from prior files queueing behind the
|
|
38
|
+
// current file's save and masking visibility of recently-committed
|
|
39
|
+
// rows. See BUNSANE-001.
|
|
40
|
+
private static pendingSideEffects: Set<Promise<void>> = new Set();
|
|
41
|
+
|
|
30
42
|
/**
|
|
31
43
|
* Await all pending background cache operations. Call during shutdown
|
|
32
44
|
* after HTTP drain but before cache.disconnect so setImmediate'd cache
|
|
@@ -45,11 +57,36 @@ export class Entity implements IEntity {
|
|
|
45
57
|
]);
|
|
46
58
|
}
|
|
47
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Await all pending post-commit side effects (cache invalidation +
|
|
62
|
+
* lifecycle hooks scheduled via queueMicrotask from save()). Call from
|
|
63
|
+
* test setup/teardown hooks under PGlite to guarantee prior-file
|
|
64
|
+
* background work has settled before the next file's saves run. Bounded
|
|
65
|
+
* by `timeoutMs`. Safe to call repeatedly; no-op when the set is empty.
|
|
66
|
+
*/
|
|
67
|
+
public static async drainPendingSideEffects(timeoutMs: number = 5_000): Promise<void> {
|
|
68
|
+
if (Entity.pendingSideEffects.size === 0) return;
|
|
69
|
+
const snapshot = [...Entity.pendingSideEffects];
|
|
70
|
+
const drainTimer = new Promise<'timeout'>((resolve) => {
|
|
71
|
+
const t = setTimeout(() => resolve('timeout'), timeoutMs);
|
|
72
|
+
t.unref?.();
|
|
73
|
+
});
|
|
74
|
+
await Promise.race([
|
|
75
|
+
Promise.allSettled(snapshot).then(() => 'drained' as const),
|
|
76
|
+
drainTimer,
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
|
|
48
80
|
private static trackCacheOp(p: Promise<void>): void {
|
|
49
81
|
Entity.pendingCacheOps.add(p);
|
|
50
82
|
p.finally(() => Entity.pendingCacheOps.delete(p));
|
|
51
83
|
}
|
|
52
84
|
|
|
85
|
+
private static trackSideEffect(p: Promise<void>): void {
|
|
86
|
+
Entity.pendingSideEffects.add(p);
|
|
87
|
+
p.finally(() => Entity.pendingSideEffects.delete(p));
|
|
88
|
+
}
|
|
89
|
+
|
|
53
90
|
constructor(id?: string) {
|
|
54
91
|
// Use || instead of ?? to also handle empty strings
|
|
55
92
|
this.id = (id && id.trim() !== '') ? id : uuidv7();
|
|
@@ -348,6 +385,81 @@ export class Entity implements IEntity {
|
|
|
348
385
|
return this._loadComponent(ctor, context);
|
|
349
386
|
}
|
|
350
387
|
|
|
388
|
+
/**
|
|
389
|
+
* Discard in-memory component state and re-hydrate from the database.
|
|
390
|
+
* Preserves entity identity — callers holding a reference see fresh data
|
|
391
|
+
* on the same instance. Use after a raw-SQL write that bypassed
|
|
392
|
+
* `entity.set`/`entity.save`, or when a different `Entity` instance with
|
|
393
|
+
* the same id mutated persisted data.
|
|
394
|
+
*
|
|
395
|
+
* @param opts Optional transaction
|
|
396
|
+
*/
|
|
397
|
+
public async reload(opts?: { trx?: SQL }): Promise<this> {
|
|
398
|
+
if (!this.id || this.id.trim() === '') {
|
|
399
|
+
return this;
|
|
400
|
+
}
|
|
401
|
+
this.components.clear();
|
|
402
|
+
this.removedComponents.clear();
|
|
403
|
+
this.savedRemovedComponents.clear();
|
|
404
|
+
|
|
405
|
+
const dbConn = opts?.trx ?? db;
|
|
406
|
+
const rows = await dbConn`
|
|
407
|
+
SELECT c.id, c.type_id, c.data
|
|
408
|
+
FROM components c
|
|
409
|
+
WHERE c.entity_id = ${this.id} AND c.deleted_at IS NULL
|
|
410
|
+
`;
|
|
411
|
+
|
|
412
|
+
const storage = getMetadataStorage();
|
|
413
|
+
for (const row of rows) {
|
|
414
|
+
const ctor = ComponentRegistry.getConstructor(row.type_id);
|
|
415
|
+
if (!ctor) continue;
|
|
416
|
+
const comp: any = new ctor();
|
|
417
|
+
const parsed = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
|
|
418
|
+
Object.assign(comp, parsed);
|
|
419
|
+
comp.id = row.id;
|
|
420
|
+
const props = storage.componentProperties.get(row.type_id);
|
|
421
|
+
if (props) {
|
|
422
|
+
for (const prop of props) {
|
|
423
|
+
if (prop.propertyType === Date && typeof comp[prop.propertyKey] === 'string') {
|
|
424
|
+
comp[prop.propertyKey] = new Date(comp[prop.propertyKey]);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
comp.setPersisted(true);
|
|
429
|
+
comp.setDirty(false);
|
|
430
|
+
this.addComponent(comp);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
this.setPersisted(true);
|
|
434
|
+
this.setDirty(false);
|
|
435
|
+
return this;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Ensure the given components are hydrated on this entity's in-memory
|
|
440
|
+
* componentList. No-op for components already loaded. Batched: one SQL
|
|
441
|
+
* call for all missing components.
|
|
442
|
+
*
|
|
443
|
+
* Required when a later `entity.set(...)` + `entity.save()` may trigger
|
|
444
|
+
* a `@ComponentTargetHook` whose `includeComponents` lists tag
|
|
445
|
+
* components. Hook matching reads `componentList()` (in-memory only), so
|
|
446
|
+
* tags must be loaded first for the hook to fire.
|
|
447
|
+
*
|
|
448
|
+
* @param ctors Component constructors to ensure are loaded
|
|
449
|
+
*/
|
|
450
|
+
public async requireComponents(ctors: Array<new (...args: any[]) => BaseComponent>): Promise<void> {
|
|
451
|
+
if (ctors.length === 0) return;
|
|
452
|
+
const missing: string[] = [];
|
|
453
|
+
for (const ctor of ctors) {
|
|
454
|
+
const existing = Array.from(this.components.values()).find(c => c instanceof ctor);
|
|
455
|
+
if (existing) continue;
|
|
456
|
+
const typeId = new ctor().getTypeID();
|
|
457
|
+
missing.push(typeId);
|
|
458
|
+
}
|
|
459
|
+
if (missing.length === 0) return;
|
|
460
|
+
await Entity.LoadComponents([this], missing);
|
|
461
|
+
}
|
|
462
|
+
|
|
351
463
|
private async _loadComponent<T extends BaseComponent>(ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL }): Promise<T | null> {
|
|
352
464
|
const comp = Array.from(this.components.values()).find(comp => comp instanceof ctor) as T | undefined;
|
|
353
465
|
if (typeof comp !== "undefined") {
|
|
@@ -465,14 +577,21 @@ export class Entity implements IEntity {
|
|
|
465
577
|
|
|
466
578
|
// Post-commit side effects are fire-and-forget so Redis / hook
|
|
467
579
|
// latency cannot consume the save budget or block the caller.
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
580
|
+
// Tracked in pendingSideEffects so tests/shutdown can drain
|
|
581
|
+
// background work before asserting or tearing down.
|
|
582
|
+
const sideEffectPromise = new Promise<void>((resolve) => {
|
|
583
|
+
queueMicrotask(() => {
|
|
584
|
+
this.runPostCommitSideEffects(
|
|
585
|
+
wasNew,
|
|
586
|
+
changedComponentTypeIds,
|
|
587
|
+
removedComponentTypeIds,
|
|
588
|
+
context,
|
|
589
|
+
profile ? phases : undefined,
|
|
590
|
+
profile ? phaseStart : undefined,
|
|
591
|
+
).finally(() => resolve());
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
Entity.trackSideEffect(sideEffectPromise);
|
|
476
595
|
|
|
477
596
|
return true;
|
|
478
597
|
} catch (error) {
|
|
@@ -645,26 +764,14 @@ export class Entity implements IEntity {
|
|
|
645
764
|
return true;
|
|
646
765
|
}
|
|
647
766
|
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
|
|
655
|
-
|
|
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);
|
|
666
|
-
}
|
|
667
|
-
};
|
|
767
|
+
// Cancellation goes through the shared `runWithSignal` helper so
|
|
768
|
+
// every db.unsafe / trx`...` callsite in the framework uses the same
|
|
769
|
+
// pattern: on abort the in-flight Bun SQL Query is cancelled, the
|
|
770
|
+
// transaction callback throws, Bun emits ROLLBACK, and the pooled
|
|
771
|
+
// backend connection is released. Without this a wall-clock timeout
|
|
772
|
+
// leaks the backend into `idle in transaction` under pgbouncer
|
|
773
|
+
// transaction-mode pooling.
|
|
774
|
+
const run = <T>(q: any): Promise<T> => runWithSignal<T>(q, signal);
|
|
668
775
|
|
|
669
776
|
const executeSave = async (saveTrx: SQL) => {
|
|
670
777
|
if (!this._persisted) {
|
|
@@ -799,19 +906,7 @@ export class Entity implements IEntity {
|
|
|
799
906
|
}, timeoutMs);
|
|
800
907
|
|
|
801
908
|
const signal = controller.signal;
|
|
802
|
-
const run =
|
|
803
|
-
if (signal.aborted) {
|
|
804
|
-
try { q.cancel?.(); } catch { /* ignore */ }
|
|
805
|
-
throw signal.reason ?? new Error('Entity.doDelete aborted');
|
|
806
|
-
}
|
|
807
|
-
const onAbort = () => { try { q.cancel?.(); } catch { /* ignore */ } };
|
|
808
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
809
|
-
try {
|
|
810
|
-
return await q;
|
|
811
|
-
} finally {
|
|
812
|
-
signal.removeEventListener('abort', onAbort);
|
|
813
|
-
}
|
|
814
|
-
};
|
|
909
|
+
const run = <T>(q: any): Promise<T> => runWithSignal<T>(q, signal);
|
|
815
910
|
|
|
816
911
|
try {
|
|
817
912
|
await db.transaction(async (trx) => {
|
package/core/RequestContext.ts
CHANGED
|
@@ -1,36 +1,85 @@
|
|
|
1
|
-
import type { Plugin } from 'graphql-yoga';
|
|
2
|
-
import { createRequestLoaders } from './RequestLoaders';
|
|
3
|
-
import type { RequestLoaders } from './RequestLoaders';
|
|
4
|
-
import db from '../database';
|
|
5
|
-
import { CacheManager } from './cache/CacheManager';
|
|
6
|
-
import { getRequestId } from './middleware/RequestId';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
1
|
+
import type { Plugin } from 'graphql-yoga';
|
|
2
|
+
import { createRequestLoaders } from './RequestLoaders';
|
|
3
|
+
import type { RequestLoaders } from './RequestLoaders';
|
|
4
|
+
import db from '../database';
|
|
5
|
+
import { CacheManager } from './cache/CacheManager';
|
|
6
|
+
import { getRequestId } from './middleware/RequestId';
|
|
7
|
+
|
|
8
|
+
export interface RequestStats {
|
|
9
|
+
operationName: string;
|
|
10
|
+
dataLoaderCalls: { entity: number; component: number; relation: number };
|
|
11
|
+
dbQueryCount: number;
|
|
12
|
+
startTime: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
declare module 'graphql-yoga' {
|
|
16
|
+
interface Context {
|
|
17
|
+
// Loaders mounted at top-level context for ArcheType resolver access
|
|
18
|
+
loaders: RequestLoaders;
|
|
19
|
+
requestId: string;
|
|
20
|
+
cacheManager: CacheManager;
|
|
21
|
+
requestStats: RequestStats;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* GraphQL Yoga plugin that creates per-request DataLoaders for batching.
|
|
28
|
+
*
|
|
29
|
+
* IMPORTANT: Loaders are mounted at context.loaders (NOT context.locals.loaders)
|
|
30
|
+
* to match what ArcheType.ts resolvers expect. This enables DataLoader batching
|
|
31
|
+
* for BelongsTo/HasMany relations, preventing N+1 queries.
|
|
32
|
+
*
|
|
33
|
+
* Also threads the request `AbortSignal` into Query/DataLoader DB calls so
|
|
34
|
+
* the framework's wall-clock timeout (handled in core/app/requestRouter.ts)
|
|
35
|
+
* cancels in-flight Postgres queries via Bun's `Query.cancel()`. Without
|
|
36
|
+
* this, an aborted request leaks its backend connection into
|
|
37
|
+
* `idle in transaction` under pgbouncer transaction-mode pooling.
|
|
38
|
+
*
|
|
39
|
+
* Captures per-request stats (operationName, DataLoader call counts,
|
|
40
|
+
* dbQueryCount) and attaches them to the underlying Request via
|
|
41
|
+
* `__bunsaneStats` so the HTTP router's catch handler + AccessLog
|
|
42
|
+
* middleware can read them after the GraphQL pipeline rejects.
|
|
43
|
+
*/
|
|
44
|
+
export function createRequestContextPlugin(): Plugin {
|
|
45
|
+
return {
|
|
46
|
+
onExecute: ({ args }) => {
|
|
47
|
+
const cacheManager = CacheManager.getInstance();
|
|
48
|
+
const ctx: any = (args as any).contextValue;
|
|
49
|
+
const request: Request | undefined = ctx?.request;
|
|
50
|
+
const signal: AbortSignal | undefined = request?.signal;
|
|
51
|
+
|
|
52
|
+
// GraphQL operation name. Falls back to first named operation in the
|
|
53
|
+
// document, or 'anonymous' if the client supplied an inline query
|
|
54
|
+
// with no name.
|
|
55
|
+
const operationName: string =
|
|
56
|
+
(typeof args.operationName === 'string' && args.operationName)
|
|
57
|
+
|| (args.document?.definitions?.find?.(
|
|
58
|
+
(d: any) => d?.kind === 'OperationDefinition' && d?.name?.value,
|
|
59
|
+
) as any)?.name?.value
|
|
60
|
+
|| 'anonymous';
|
|
61
|
+
|
|
62
|
+
const stats: RequestStats = {
|
|
63
|
+
operationName,
|
|
64
|
+
dataLoaderCalls: { entity: 0, component: 0, relation: 0 },
|
|
65
|
+
dbQueryCount: 0,
|
|
66
|
+
startTime: performance.now(),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Mount loaders at context.loaders to match ArcheType.ts resolver access pattern.
|
|
70
|
+
ctx.loaders = createRequestLoaders(db, cacheManager, signal, stats);
|
|
71
|
+
// Prefer the HTTP-layer request id (from requestId() middleware's
|
|
72
|
+
// AsyncLocalStorage) so access log + GraphQL logs share the same id.
|
|
73
|
+
ctx.requestId = getRequestId() ?? crypto.randomUUID();
|
|
74
|
+
ctx.cacheManager = cacheManager;
|
|
75
|
+
ctx.requestStats = stats;
|
|
76
|
+
ctx.signal = signal;
|
|
77
|
+
|
|
78
|
+
// Attach to the raw Request so the HTTP router catch block + access
|
|
79
|
+
// log middleware can read stats after Yoga rejects.
|
|
80
|
+
if (request) {
|
|
81
|
+
(request as any).__bunsaneStats = stats;
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
package/core/RequestLoaders.ts
CHANGED
|
@@ -2,10 +2,12 @@ import DataLoader from 'dataloader';
|
|
|
2
2
|
import { Entity } from './Entity';
|
|
3
3
|
import db from '../database';
|
|
4
4
|
import { inList } from '../database/sqlHelpers';
|
|
5
|
+
import { timedUnsafe, incrementDataLoaderCall, type PerRequestCounters } from '../database/instrumentedDb';
|
|
5
6
|
import {logger as MainLogger} from './Logger';
|
|
6
7
|
const logger = MainLogger.child({ module: 'RequestLoaders' });
|
|
7
8
|
import { getMetadataStorage } from './metadata';
|
|
8
9
|
import type { CacheManager } from './cache/CacheManager';
|
|
10
|
+
import { COMPONENT_TOMBSTONE } from './cache/CacheManager';
|
|
9
11
|
|
|
10
12
|
export type ComponentData = {
|
|
11
13
|
id: string; // Component ID for updates
|
|
@@ -23,8 +25,14 @@ export type RequestLoaders = {
|
|
|
23
25
|
relationsByEntityField: DataLoader<{ entityId: string; relationField: string; relatedType: string; foreignKey?: string }, Entity[]>;
|
|
24
26
|
};
|
|
25
27
|
|
|
26
|
-
export function createRequestLoaders(
|
|
28
|
+
export function createRequestLoaders(
|
|
29
|
+
db: any,
|
|
30
|
+
cacheManager?: CacheManager,
|
|
31
|
+
signal?: AbortSignal,
|
|
32
|
+
perRequest?: PerRequestCounters,
|
|
33
|
+
): RequestLoaders {
|
|
27
34
|
const entityById = new DataLoader<string, Entity | null>(async (ids: readonly string[]) => {
|
|
35
|
+
incrementDataLoaderCall('entity', perRequest);
|
|
28
36
|
const startTime = Date.now();
|
|
29
37
|
try {
|
|
30
38
|
// Filter out empty/invalid IDs to prevent PostgreSQL UUID parsing errors
|
|
@@ -44,12 +52,12 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
44
52
|
|
|
45
53
|
if (missingIds.length > 0) {
|
|
46
54
|
const idList = inList(missingIds, 1);
|
|
47
|
-
const rows = await db
|
|
55
|
+
const rows = await timedUnsafe<any[]>(db, `
|
|
48
56
|
SELECT id
|
|
49
57
|
FROM entities
|
|
50
58
|
WHERE id IN ${idList.sql}
|
|
51
59
|
AND deleted_at IS NULL
|
|
52
|
-
`, idList.params);
|
|
60
|
+
`, idList.params, signal, perRequest);
|
|
53
61
|
|
|
54
62
|
const entities = rows.map((row: any) => {
|
|
55
63
|
const entity = new Entity(row.id);
|
|
@@ -89,6 +97,7 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
89
97
|
|
|
90
98
|
const componentsByEntityType = new DataLoader<{ entityId: string; typeId: string }, ComponentData | null>(
|
|
91
99
|
async (keys: readonly { entityId: string; typeId: string }[]) => {
|
|
100
|
+
incrementDataLoaderCall('component', perRequest);
|
|
92
101
|
const startTime = Date.now();
|
|
93
102
|
try {
|
|
94
103
|
// Filter out keys with empty/invalid entity IDs to prevent PostgreSQL UUID parsing errors
|
|
@@ -99,16 +108,20 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
99
108
|
|
|
100
109
|
const results = new Map<string, ComponentData | null>();
|
|
101
110
|
|
|
102
|
-
// Check cache first if cache manager is available
|
|
111
|
+
// Check cache first if cache manager is available. Tombstone hits
|
|
112
|
+
// are recorded as null in `results` so the DB-fetch step skips them.
|
|
103
113
|
let cacheHits = 0;
|
|
104
114
|
let cacheMisses = 0;
|
|
105
115
|
if (cacheManager && cacheManager.getConfig().enabled && cacheManager.getConfig().component?.enabled) {
|
|
106
116
|
try {
|
|
107
117
|
const cachedComponents = await cacheManager.getComponents(validKeys);
|
|
108
|
-
cachedComponents.forEach((
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
results.set(key,
|
|
118
|
+
cachedComponents.forEach((value, index) => {
|
|
119
|
+
const key = `${validKeys[index]!.entityId}-${validKeys[index]!.typeId}`;
|
|
120
|
+
if (value === COMPONENT_TOMBSTONE) {
|
|
121
|
+
results.set(key, null);
|
|
122
|
+
cacheHits++;
|
|
123
|
+
} else if (value) {
|
|
124
|
+
results.set(key, value);
|
|
112
125
|
cacheHits++;
|
|
113
126
|
} else {
|
|
114
127
|
cacheMisses++;
|
|
@@ -122,17 +135,16 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
122
135
|
cacheMisses += validKeys.length;
|
|
123
136
|
}
|
|
124
137
|
|
|
125
|
-
// Log cache hit/miss rates for monitoring
|
|
126
138
|
if (validKeys.length > 0) {
|
|
127
139
|
const hitRate = (cacheHits / validKeys.length) * 100;
|
|
128
|
-
logger.
|
|
129
|
-
scope: 'cache',
|
|
130
|
-
component: 'RequestLoaders',
|
|
131
|
-
msg: 'Component cache statistics',
|
|
132
|
-
total: validKeys.length,
|
|
133
|
-
hits: cacheHits,
|
|
134
|
-
misses: cacheMisses,
|
|
135
|
-
hitRate: `${hitRate.toFixed(1)}
|
|
140
|
+
logger.trace({
|
|
141
|
+
scope: 'cache',
|
|
142
|
+
component: 'RequestLoaders',
|
|
143
|
+
msg: 'Component cache statistics',
|
|
144
|
+
total: validKeys.length,
|
|
145
|
+
hits: cacheHits,
|
|
146
|
+
misses: cacheMisses,
|
|
147
|
+
hitRate: `${hitRate.toFixed(1)}%`,
|
|
136
148
|
});
|
|
137
149
|
}
|
|
138
150
|
|
|
@@ -144,13 +156,13 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
144
156
|
const typeIds = [...new Set(missingKeys.map(k => k.typeId))];
|
|
145
157
|
const entityIdList = inList(entityIds, 1);
|
|
146
158
|
const typeIdList = inList(typeIds, entityIdList.newParamIndex);
|
|
147
|
-
const rows = await db
|
|
159
|
+
const rows = await timedUnsafe<any[]>(db, `
|
|
148
160
|
SELECT id, entity_id, type_id, data, created_at, updated_at, deleted_at
|
|
149
161
|
FROM components
|
|
150
162
|
WHERE entity_id IN ${entityIdList.sql}
|
|
151
163
|
AND type_id IN ${typeIdList.sql}
|
|
152
164
|
AND deleted_at IS NULL
|
|
153
|
-
`, [...entityIdList.params, ...typeIdList.params]);
|
|
165
|
+
`, [...entityIdList.params, ...typeIdList.params], signal, perRequest);
|
|
154
166
|
|
|
155
167
|
const components: ComponentData[] = rows.map((row: any) => ({
|
|
156
168
|
id: row.id,
|
|
@@ -162,10 +174,15 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
162
174
|
deletedAt: row.deleted_at,
|
|
163
175
|
}));
|
|
164
176
|
|
|
165
|
-
// Cache the loaded components
|
|
177
|
+
// Cache the loaded components + tombstone any requested keys whose
|
|
178
|
+
// row was absent (single setMany — see CacheManager.setComponentsWriteThrough).
|
|
166
179
|
if (cacheManager && cacheManager.getConfig().enabled && cacheManager.getConfig().component?.enabled) {
|
|
167
180
|
try {
|
|
168
|
-
await cacheManager.setComponentsWriteThrough(
|
|
181
|
+
await cacheManager.setComponentsWriteThrough(
|
|
182
|
+
components,
|
|
183
|
+
missingKeys,
|
|
184
|
+
cacheManager.getConfig().component!.ttl,
|
|
185
|
+
);
|
|
169
186
|
} catch (error: any) {
|
|
170
187
|
logger.warn({ scope: 'cache', component: 'RequestLoaders', msg: 'Cache write failed for components', error });
|
|
171
188
|
}
|
|
@@ -199,6 +216,7 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
199
216
|
|
|
200
217
|
const relationsByEntityField = new DataLoader<{ entityId: string; relationField: string; relatedType: string; foreignKey?: string }, Entity[]>(
|
|
201
218
|
async (keys: readonly { entityId: string; relationField: string; relatedType: string; foreignKey?: string }[]) => {
|
|
219
|
+
incrementDataLoaderCall('relation', perRequest);
|
|
202
220
|
const startTime = Date.now();
|
|
203
221
|
try {
|
|
204
222
|
// Filter valid keys
|
|
@@ -207,9 +225,35 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
207
225
|
return keys.map(() => []);
|
|
208
226
|
}
|
|
209
227
|
|
|
228
|
+
const resultMap = new Map<string, Entity[]>();
|
|
229
|
+
|
|
230
|
+
// Negative-cache lookup: skip DB for keys recorded as empty.
|
|
231
|
+
let keysToQuery = validKeys;
|
|
232
|
+
const relCacheEnabled = !!(cacheManager
|
|
233
|
+
&& cacheManager.getConfig().enabled
|
|
234
|
+
&& cacheManager.getConfig().relation?.negativeCacheEnabled);
|
|
235
|
+
if (relCacheEnabled) {
|
|
236
|
+
try {
|
|
237
|
+
const tombstones = await cacheManager!.getRelationsEmpty(validKeys);
|
|
238
|
+
const remaining: typeof validKeys = [];
|
|
239
|
+
tombstones.forEach((isEmpty, i) => {
|
|
240
|
+
const k = validKeys[i]!;
|
|
241
|
+
if (isEmpty) {
|
|
242
|
+
const mapKey = `${k.entityId}\x00${k.relationField}\x00${k.relatedType}`;
|
|
243
|
+
resultMap.set(mapKey, []);
|
|
244
|
+
} else {
|
|
245
|
+
remaining.push(k);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
keysToQuery = remaining;
|
|
249
|
+
} catch (error) {
|
|
250
|
+
logger.warn({ scope: 'cache', component: 'RequestLoaders', msg: 'Cache read failed for relation tombstones', error });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
210
254
|
// Group keys by foreign key for efficient batching
|
|
211
|
-
const keysByForeignKey = new Map<string, typeof
|
|
212
|
-
for (const key of
|
|
255
|
+
const keysByForeignKey = new Map<string, typeof keysToQuery>();
|
|
256
|
+
for (const key of keysToQuery) {
|
|
213
257
|
const fk = key.foreignKey || 'default';
|
|
214
258
|
if (!keysByForeignKey.has(fk)) {
|
|
215
259
|
keysByForeignKey.set(fk, []);
|
|
@@ -217,8 +261,6 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
217
261
|
keysByForeignKey.get(fk)!.push(key);
|
|
218
262
|
}
|
|
219
263
|
|
|
220
|
-
const resultMap = new Map<string, Entity[]>();
|
|
221
|
-
|
|
222
264
|
// OPTIMIZED: Batch query for each foreign key type (instead of N separate queries)
|
|
223
265
|
for (const [foreignKey, groupedKeys] of keysByForeignKey) {
|
|
224
266
|
const entityIds = [...new Set(groupedKeys.map(k => k.entityId))];
|
|
@@ -240,19 +282,19 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
240
282
|
logger.trace(`[RelationLoader] Batched query for ${groupedKeys.length} keys with foreign key ${foreignKey}`);
|
|
241
283
|
|
|
242
284
|
// SINGLE BATCHED QUERY for all entities in this group
|
|
243
|
-
const rows = await db
|
|
244
|
-
SELECT DISTINCT
|
|
245
|
-
c.entity_id,
|
|
246
|
-
c.data,
|
|
285
|
+
const rows = await timedUnsafe<any[]>(db, `
|
|
286
|
+
SELECT DISTINCT
|
|
287
|
+
c.entity_id,
|
|
288
|
+
c.data,
|
|
247
289
|
c.type_id,
|
|
248
290
|
c.data->>'${foreignKeyField}' as fk_value,
|
|
249
291
|
COALESCE(c.data->>'user_id', c.data->>'parent_id') as fallback_fk_value
|
|
250
292
|
FROM components c
|
|
251
293
|
INNER JOIN entities e ON c.entity_id = e.id
|
|
252
|
-
WHERE e.deleted_at IS NULL
|
|
294
|
+
WHERE e.deleted_at IS NULL
|
|
253
295
|
AND c.deleted_at IS NULL
|
|
254
296
|
AND ${whereClause}
|
|
255
|
-
`, [entityIds]);
|
|
297
|
+
`, [entityIds], signal, perRequest);
|
|
256
298
|
|
|
257
299
|
logger.trace(`[RelationLoader] Found ${rows.length} total components for ${entityIds.length} entities`);
|
|
258
300
|
|
|
@@ -281,6 +323,22 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
281
323
|
}
|
|
282
324
|
}
|
|
283
325
|
|
|
326
|
+
// Write tombstones for queried keys whose result was empty.
|
|
327
|
+
if (relCacheEnabled && keysToQuery.length > 0) {
|
|
328
|
+
const emptyKeys = keysToQuery.filter(k => {
|
|
329
|
+
const mapKey = `${k.entityId}\x00${k.relationField}\x00${k.relatedType}`;
|
|
330
|
+
const r = resultMap.get(mapKey);
|
|
331
|
+
return !r || r.length === 0;
|
|
332
|
+
});
|
|
333
|
+
if (emptyKeys.length > 0) {
|
|
334
|
+
try {
|
|
335
|
+
await cacheManager!.setRelationsEmpty(emptyKeys);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
logger.warn({ scope: 'cache', component: 'RequestLoaders', msg: 'Cache write failed for relation tombstones', error });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
284
342
|
const duration = Date.now() - startTime;
|
|
285
343
|
if (duration > 1000) {
|
|
286
344
|
logger.warn(`Slow relationsByEntityField query: ${duration}ms for ${keys.length} keys`);
|
package/core/SchedulerManager.ts
CHANGED
|
@@ -109,12 +109,10 @@ export class SchedulerManager {
|
|
|
109
109
|
throw error;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
throw error;
|
|
117
|
-
}
|
|
112
|
+
// Time-based tasks (no query, no componentTarget) are allowed — they
|
|
113
|
+
// invoke the handler with no entity arguments on each tick. Useful
|
|
114
|
+
// for external polling, stats aggregation, or ad-hoc queries inside
|
|
115
|
+
// the callback.
|
|
118
116
|
|
|
119
117
|
if (!taskInfo.service) {
|
|
120
118
|
const error = new Error(`Task ${taskInfo.id} has no service instance`);
|
|
@@ -395,7 +393,7 @@ export class SchedulerManager {
|
|
|
395
393
|
try {
|
|
396
394
|
// Create query based on targeting configuration
|
|
397
395
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
398
|
-
let query: Query<any
|
|
396
|
+
let query: Query<any> | null = null;
|
|
399
397
|
|
|
400
398
|
if (taskInfo.options?.query) {
|
|
401
399
|
// Use custom query function (preferred approach)
|
|
@@ -407,16 +405,16 @@ export class SchedulerManager {
|
|
|
407
405
|
} else if (taskInfo.componentTarget) {
|
|
408
406
|
// Use legacy single component targeting (deprecated - use query instead)
|
|
409
407
|
query = new Query().with(taskInfo.componentTarget);
|
|
410
|
-
} else {
|
|
411
|
-
throw new Error('No query function or component target specified');
|
|
412
408
|
}
|
|
409
|
+
// else: time-based task — no entity selection. Handler invoked
|
|
410
|
+
// with no arguments on each tick.
|
|
413
411
|
|
|
414
412
|
// Apply entity limit if specified (can be used with query function)
|
|
415
|
-
if (taskInfo.options?.maxEntitiesPerExecution) {
|
|
413
|
+
if (query && taskInfo.options?.maxEntitiesPerExecution) {
|
|
416
414
|
query.take(taskInfo.options.maxEntitiesPerExecution);
|
|
417
415
|
}
|
|
418
416
|
|
|
419
|
-
const entities = await query.exec();
|
|
417
|
+
const entities = query ? await query.exec() : [];
|
|
420
418
|
|
|
421
419
|
// Execute the scheduled method with the entities array
|
|
422
420
|
const method = taskInfo.service[taskInfo.methodName];
|
|
@@ -424,9 +422,11 @@ export class SchedulerManager {
|
|
|
424
422
|
throw new Error(`Method ${taskInfo.methodName} not found on service`);
|
|
425
423
|
}
|
|
426
424
|
|
|
427
|
-
// Execute with timeout
|
|
425
|
+
// Execute with timeout. Time-based tasks receive no entity arg.
|
|
428
426
|
const result = await this.executeWithTimeout(
|
|
429
|
-
|
|
427
|
+
query
|
|
428
|
+
? method.call(taskInfo.service, entities)
|
|
429
|
+
: method.call(taskInfo.service),
|
|
430
430
|
timeout,
|
|
431
431
|
taskInfo
|
|
432
432
|
);
|