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.
Files changed (54) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/CHANGELOG.md +104 -0
  3. package/CLAUDE.md +20 -0
  4. package/config/cache.config.ts +35 -1
  5. package/core/App.ts +24 -1060
  6. package/core/ArcheType.ts +78 -2110
  7. package/core/Entity.ts +136 -41
  8. package/core/RequestContext.ts +85 -36
  9. package/core/RequestLoaders.ts +89 -31
  10. package/core/SchedulerManager.ts +13 -13
  11. package/core/app/bootstrap.ts +133 -0
  12. package/core/app/cors.ts +94 -0
  13. package/core/app/graphqlSetup.ts +56 -0
  14. package/core/app/healthEndpoints.ts +31 -0
  15. package/core/app/metricsCollector.ts +27 -0
  16. package/core/app/preparedStatementWarmup.ts +55 -0
  17. package/core/app/processHandlers.ts +43 -0
  18. package/core/app/requestRouter.ts +309 -0
  19. package/core/app/restRegistry.ts +72 -0
  20. package/core/app/shutdown.ts +97 -0
  21. package/core/app/studioRouter.ts +83 -0
  22. package/core/archetype/customTypes.ts +100 -0
  23. package/core/archetype/decorators.ts +171 -0
  24. package/core/archetype/fieldResolvers.ts +621 -0
  25. package/core/archetype/helpers.ts +29 -0
  26. package/core/archetype/relationLoader.ts +118 -0
  27. package/core/archetype/schemaBuilder.ts +141 -0
  28. package/core/archetype/weaver.ts +218 -0
  29. package/core/archetype/zodSchemaBuilder.ts +527 -0
  30. package/core/cache/CacheManager.ts +144 -9
  31. package/core/components/BaseComponent.ts +12 -2
  32. package/core/middleware/AccessLog.ts +8 -1
  33. package/database/PreparedStatementCache.ts +17 -16
  34. package/database/cancellable.ts +22 -0
  35. package/database/instrumentedDb.ts +141 -0
  36. package/docs/RFC_APP_REFACTOR.md +248 -0
  37. package/docs/RFC_REFACTOR_TARGETS.md +251 -0
  38. package/package.json +1 -1
  39. package/query/ComponentInclusionNode.ts +5 -5
  40. package/query/Query.ts +65 -48
  41. package/service/ServiceRegistry.ts +7 -1
  42. package/service/index.ts +4 -2
  43. package/tests/integration/loaders/RequestLoaders.abort.test.ts +82 -0
  44. package/tests/integration/query/Query.abort.test.ts +66 -0
  45. package/tests/unit/cache/CacheManager.test.ts +152 -1
  46. package/tests/unit/database/cancellable.test.ts +81 -0
  47. package/tests/unit/database/instrumentedDb.test.ts +160 -0
  48. package/tests/unit/entity/Entity.components.test.ts +73 -0
  49. package/tests/unit/entity/Entity.drainSideEffects.test.ts +51 -0
  50. package/tests/unit/entity/Entity.reload.test.ts +63 -0
  51. package/tests/unit/entity/Entity.requireComponents.test.ts +72 -0
  52. package/tests/unit/query/Query.emptyString.test.ts +69 -0
  53. package/tests/unit/query/Query.test.ts +6 -4
  54. 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
- queueMicrotask(() => this.runPostCommitSideEffects(
469
- wasNew,
470
- changedComponentTypeIds,
471
- removedComponentTypeIds,
472
- context,
473
- profile ? phases : undefined,
474
- profile ? phaseStart : undefined,
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
- // 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);
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 = 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');
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) => {
@@ -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
- declare module 'graphql-yoga' {
9
- interface Context {
10
- // Loaders mounted at top-level context for ArcheType resolver access
11
- loaders: RequestLoaders;
12
- requestId: string;
13
- cacheManager: CacheManager;
14
- }
15
- }
16
-
17
- /**
18
- * GraphQL Yoga plugin that creates per-request DataLoaders for batching.
19
- *
20
- * IMPORTANT: Loaders are mounted at context.loaders (NOT context.locals.loaders)
21
- * to match what ArcheType.ts resolvers expect. This enables DataLoader batching
22
- * for BelongsTo/HasMany relations, preventing N+1 queries.
23
- */
24
- export function createRequestContextPlugin(): Plugin {
25
- return {
26
- onExecute: ({ args }) => {
27
- const cacheManager = CacheManager.getInstance();
28
- // Mount loaders at context.loaders to match ArcheType.ts resolver access pattern
29
- (args as any).contextValue.loaders = createRequestLoaders(db, cacheManager);
30
- // Prefer the HTTP-layer request id (from requestId() middleware's
31
- // AsyncLocalStorage) so access log + GraphQL logs share the same id.
32
- (args as any).contextValue.requestId = getRequestId() ?? crypto.randomUUID();
33
- (args as any).contextValue.cacheManager = cacheManager;
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
+ }
@@ -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(db: any, cacheManager?: CacheManager): RequestLoaders {
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.unsafe(`
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((component, index) => {
109
- if (component) {
110
- const key = `${validKeys[index]!.entityId}-${validKeys[index]!.typeId}`;
111
- results.set(key, component);
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.debug({
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.unsafe(`
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 if cache is enabled
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(components, cacheManager.getConfig().component!.ttl);
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 validKeys>();
212
- for (const key of validKeys) {
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.unsafe(`
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`);
@@ -109,12 +109,10 @@ export class SchedulerManager {
109
109
  throw error;
110
110
  }
111
111
 
112
- // Validate query configuration
113
- if (!taskInfo.options?.query && !taskInfo.options?.componentTarget && !taskInfo.componentTarget) {
114
- const error = new Error(`Invalid task info: must provide either query function, componentTarget config, or legacy componentTarget`);
115
- loggerInstance.error(`Failed to register task: ${error.message}`);
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
- method.call(taskInfo.service, entities),
427
+ query
428
+ ? method.call(taskInfo.service, entities)
429
+ : method.call(taskInfo.service),
430
430
  timeout,
431
431
  taskInfo
432
432
  );