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
|
@@ -15,6 +15,16 @@ interface InvalidationMessage {
|
|
|
15
15
|
pattern?: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Sentinel value written to the cache to record "known absent" lookups.
|
|
20
|
+
* String literal (not object) so it round-trips cleanly through
|
|
21
|
+
* JSON.stringify in RedisCache + CompressionUtils. Callers must treat it
|
|
22
|
+
* as a cache hit but propagate a `null`/`[]` upstream.
|
|
23
|
+
*/
|
|
24
|
+
export const COMPONENT_TOMBSTONE = '__TOMBSTONE__' as const;
|
|
25
|
+
export const RELATION_TOMBSTONE = '__TOMBSTONE__' as const;
|
|
26
|
+
export type ComponentCacheValue = ComponentData | typeof COMPONENT_TOMBSTONE;
|
|
27
|
+
|
|
18
28
|
/**
|
|
19
29
|
* High-level cache operations manager
|
|
20
30
|
* Singleton that provides entity and component caching methods
|
|
@@ -284,6 +294,24 @@ export class CacheManager {
|
|
|
284
294
|
}
|
|
285
295
|
}
|
|
286
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Invalidate cached state (entity + all components) for a batch of
|
|
299
|
+
* entity IDs. Call this after a raw-SQL write (db.unsafe) that bypasses
|
|
300
|
+
* Entity.set/save, so downstream reads observe fresh data instead of
|
|
301
|
+
* stale L1/L2 cache entries.
|
|
302
|
+
*/
|
|
303
|
+
public async invalidateEntities(entityIds: string[]): Promise<void> {
|
|
304
|
+
if (!this.config.enabled || entityIds.length === 0) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
await Promise.all(
|
|
308
|
+
entityIds.flatMap(id => [
|
|
309
|
+
this.invalidateEntity(id),
|
|
310
|
+
this.invalidateAllEntityComponents(id),
|
|
311
|
+
])
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
287
315
|
/**
|
|
288
316
|
* Invalidate all components for a specific entity from cache
|
|
289
317
|
* Uses pattern matching to efficiently clear all component caches for an entity
|
|
@@ -303,16 +331,18 @@ export class CacheManager {
|
|
|
303
331
|
}
|
|
304
332
|
|
|
305
333
|
/**
|
|
306
|
-
* Get components by entity and type from cache (for DataLoader integration)
|
|
334
|
+
* Get components by entity and type from cache (for DataLoader integration).
|
|
335
|
+
* Returns COMPONENT_TOMBSTONE for keys whose absence was previously
|
|
336
|
+
* recorded; callers must treat this as a hit and propagate null upstream.
|
|
307
337
|
*/
|
|
308
|
-
public async getComponents(keys: Array<{ entityId: string; typeId: string }>): Promise<(
|
|
338
|
+
public async getComponents(keys: Array<{ entityId: string; typeId: string }>): Promise<(ComponentCacheValue | null)[]> {
|
|
309
339
|
if (!this.config.enabled || !this.config.component?.enabled) {
|
|
310
340
|
return keys.map(() => null);
|
|
311
341
|
}
|
|
312
342
|
|
|
313
343
|
try {
|
|
314
344
|
const cacheKeys = keys.map(k => `component:${k.entityId}:${k.typeId}`);
|
|
315
|
-
const results = await this.provider.getMany<
|
|
345
|
+
const results = await this.provider.getMany<ComponentCacheValue>(cacheKeys);
|
|
316
346
|
return results;
|
|
317
347
|
} catch (error) {
|
|
318
348
|
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting components from cache', error });
|
|
@@ -321,26 +351,131 @@ export class CacheManager {
|
|
|
321
351
|
}
|
|
322
352
|
|
|
323
353
|
/**
|
|
324
|
-
* Set components in cache with write-through strategy (for DataLoader integration)
|
|
354
|
+
* Set components in cache with write-through strategy (for DataLoader integration).
|
|
355
|
+
*
|
|
356
|
+
* When `requestedKeys` is supplied and `component.negativeCacheEnabled` is
|
|
357
|
+
* true, tombstones are written for any requested key not present in
|
|
358
|
+
* `components` (within the same setMany call — single round-trip).
|
|
325
359
|
*/
|
|
326
|
-
public async setComponentsWriteThrough(
|
|
360
|
+
public async setComponentsWriteThrough(
|
|
361
|
+
components: ComponentData[],
|
|
362
|
+
ttlOrRequested?: number | Array<{ entityId: string; typeId: string }>,
|
|
363
|
+
ttlIfRequested?: number,
|
|
364
|
+
): Promise<void> {
|
|
327
365
|
if (!this.config.enabled || !this.config.component?.enabled) {
|
|
328
366
|
return;
|
|
329
367
|
}
|
|
330
368
|
|
|
369
|
+
// Backward-compatible overload: (components, ttl?) or (components, requestedKeys, ttl?)
|
|
370
|
+
const requestedKeys = Array.isArray(ttlOrRequested) ? ttlOrRequested : undefined;
|
|
371
|
+
const ttl = Array.isArray(ttlOrRequested) ? ttlIfRequested : ttlOrRequested;
|
|
372
|
+
|
|
331
373
|
try {
|
|
332
|
-
const
|
|
333
|
-
const entries = components.map(comp => ({
|
|
374
|
+
const componentTTL = ttl ?? this.config.component.ttl;
|
|
375
|
+
const entries: Array<{ key: string; value: ComponentCacheValue; ttl: number }> = components.map(comp => ({
|
|
334
376
|
key: `component:${comp.entityId}:${comp.typeId}`,
|
|
335
377
|
value: comp,
|
|
336
|
-
ttl:
|
|
378
|
+
ttl: componentTTL,
|
|
337
379
|
}));
|
|
338
|
-
|
|
380
|
+
|
|
381
|
+
const negativeEnabled = this.config.component.negativeCacheEnabled === true;
|
|
382
|
+
if (negativeEnabled && requestedKeys && requestedKeys.length > 0) {
|
|
383
|
+
const found = new Set(components.map(c => `${c.entityId}-${c.typeId}`));
|
|
384
|
+
const tombstoneTTL = this.config.component.negativeCacheTtl
|
|
385
|
+
?? Math.min(componentTTL, 60_000);
|
|
386
|
+
for (const k of requestedKeys) {
|
|
387
|
+
const dedupeKey = `${k.entityId}-${k.typeId}`;
|
|
388
|
+
if (!found.has(dedupeKey)) {
|
|
389
|
+
entries.push({
|
|
390
|
+
key: `component:${k.entityId}:${k.typeId}`,
|
|
391
|
+
value: COMPONENT_TOMBSTONE,
|
|
392
|
+
ttl: tombstoneTTL,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (entries.length > 0) {
|
|
399
|
+
await this.provider.setMany(entries);
|
|
400
|
+
}
|
|
339
401
|
} catch (error) {
|
|
340
402
|
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting components in cache', error });
|
|
341
403
|
}
|
|
342
404
|
}
|
|
343
405
|
|
|
406
|
+
// Relation negative-cache methods
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Build the cache key for a relation tombstone. Null byte separator
|
|
410
|
+
* prevents collision when relationField contains hyphens or colons.
|
|
411
|
+
*/
|
|
412
|
+
private static relationCacheKey(entityId: string, relationField: string, relatedType: string, foreignKey?: string): string {
|
|
413
|
+
const fk = foreignKey ?? '';
|
|
414
|
+
return `relation:${entityId}\x00${relationField}\x00${relatedType}\x00${fk}`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Bulk-check relation tombstones. Returns true at index i when the
|
|
419
|
+
* relation at keys[i] was previously recorded as empty.
|
|
420
|
+
*/
|
|
421
|
+
public async getRelationsEmpty(
|
|
422
|
+
keys: Array<{ entityId: string; relationField: string; relatedType: string; foreignKey?: string }>,
|
|
423
|
+
): Promise<boolean[]> {
|
|
424
|
+
if (!this.config.enabled || !this.config.relation?.negativeCacheEnabled) {
|
|
425
|
+
return keys.map(() => false);
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
const cacheKeys = keys.map(k => CacheManager.relationCacheKey(k.entityId, k.relationField, k.relatedType, k.foreignKey));
|
|
429
|
+
const values = await this.provider.getMany<string>(cacheKeys);
|
|
430
|
+
return values.map(v => v === RELATION_TOMBSTONE);
|
|
431
|
+
} catch (error) {
|
|
432
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting relation tombstones', error });
|
|
433
|
+
return keys.map(() => false);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Record relation tombstones for keys whose query returned []. TTL
|
|
439
|
+
* defaults to relation.negativeCacheTtl (60s).
|
|
440
|
+
*/
|
|
441
|
+
public async setRelationsEmpty(
|
|
442
|
+
keys: Array<{ entityId: string; relationField: string; relatedType: string; foreignKey?: string }>,
|
|
443
|
+
ttl?: number,
|
|
444
|
+
): Promise<void> {
|
|
445
|
+
if (!this.config.enabled || !this.config.relation?.negativeCacheEnabled || keys.length === 0) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
const effectiveTTL = ttl ?? this.config.relation.negativeCacheTtl ?? 60_000;
|
|
450
|
+
const entries = keys.map(k => ({
|
|
451
|
+
key: CacheManager.relationCacheKey(k.entityId, k.relationField, k.relatedType, k.foreignKey),
|
|
452
|
+
value: RELATION_TOMBSTONE,
|
|
453
|
+
ttl: effectiveTTL,
|
|
454
|
+
}));
|
|
455
|
+
await this.provider.setMany(entries);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error setting relation tombstones', error });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Drop a relation tombstone. Call when a target component is created
|
|
463
|
+
* that may newly satisfy the relation. Pub/sub invalidation is wired
|
|
464
|
+
* identically to component invalidation.
|
|
465
|
+
*/
|
|
466
|
+
public async invalidateRelation(entityId: string, relationField: string, relatedType: string, foreignKey?: string): Promise<void> {
|
|
467
|
+
if (!this.config.enabled || !this.config.relation?.negativeCacheEnabled) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
const key = CacheManager.relationCacheKey(entityId, relationField, relatedType, foreignKey);
|
|
472
|
+
await this.provider.delete(key);
|
|
473
|
+
await this.publishInvalidation('key', [key]);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating relation tombstone', error });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
344
479
|
// Generic cache methods
|
|
345
480
|
|
|
346
481
|
/**
|
|
@@ -55,8 +55,18 @@ export class BaseComponent {
|
|
|
55
55
|
this.properties().forEach((prop: string) => {
|
|
56
56
|
let value = (this as any)[prop];
|
|
57
57
|
const propMeta = props?.find(p => p.propertyKey === prop);
|
|
58
|
-
if (
|
|
59
|
-
|
|
58
|
+
if (value !== null && value !== undefined) {
|
|
59
|
+
if (propMeta?.propertyType === Date) {
|
|
60
|
+
if (!(value instanceof Date)) {
|
|
61
|
+
throw new Error(`Type mismatch for property '${prop}' on component '${this._comp_name}': expected Date, got ${typeof value}`);
|
|
62
|
+
}
|
|
63
|
+
if (Number.isNaN(value.getTime())) {
|
|
64
|
+
throw new Error(`Invalid Date for property '${prop}' on component '${this._comp_name}'`);
|
|
65
|
+
}
|
|
66
|
+
value = value.toISOString();
|
|
67
|
+
} else if (propMeta?.propertyType === Number && typeof value === 'number' && !Number.isFinite(value)) {
|
|
68
|
+
throw new Error(`Invalid number for property '${prop}' on component '${this._comp_name}': ${value}`);
|
|
69
|
+
}
|
|
60
70
|
}
|
|
61
71
|
data[prop] = value;
|
|
62
72
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Middleware } from '../Middleware';
|
|
2
2
|
import { logger as MainLogger } from '../Logger';
|
|
3
3
|
import { getRequestId } from './RequestId';
|
|
4
|
+
import type { RequestStats } from '../RequestContext';
|
|
4
5
|
|
|
5
6
|
const logger = MainLogger.child({ scope: 'HTTP' });
|
|
6
7
|
|
|
@@ -37,7 +38,8 @@ export function accessLog(options: AccessLogOptions = {}): Middleware {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
const duration = Math.round(performance.now() - start);
|
|
40
|
-
const
|
|
41
|
+
const stats = (req as any).__bunsaneStats as RequestStats | undefined;
|
|
42
|
+
const logData: Record<string, any> = {
|
|
41
43
|
requestId: getRequestId(),
|
|
42
44
|
method: req.method,
|
|
43
45
|
path: url.pathname,
|
|
@@ -45,6 +47,11 @@ export function accessLog(options: AccessLogOptions = {}): Middleware {
|
|
|
45
47
|
duration,
|
|
46
48
|
msg: `${req.method} ${url.pathname} ${response.status} ${duration}ms`,
|
|
47
49
|
};
|
|
50
|
+
if (stats) {
|
|
51
|
+
logData.operationName = stats.operationName;
|
|
52
|
+
logData.dataLoaderCalls = stats.dataLoaderCalls;
|
|
53
|
+
logData.dbQueryCount = stats.dbQueryCount;
|
|
54
|
+
}
|
|
48
55
|
|
|
49
56
|
if (response.status >= 500) {
|
|
50
57
|
logger.error(logData);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { logger } from "../core/Logger";
|
|
2
|
+
import { timedUnsafe, type PerRequestCounters } from "./instrumentedDb";
|
|
2
3
|
|
|
3
4
|
export interface CacheEntry {
|
|
4
5
|
sql: string;
|
|
@@ -108,23 +109,23 @@ export class PreparedStatementCache {
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
/**
|
|
111
|
-
* Execute a prepared statement with parameters
|
|
112
|
+
* Execute a prepared statement with parameters. Routes through
|
|
113
|
+
* `timedUnsafe` so the call is timed and (when a signal is supplied)
|
|
114
|
+
* cancellable via Bun's `Query.cancel()` on abort.
|
|
112
115
|
*/
|
|
113
|
-
public async execute(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
// In a real implementation, this might use a prepared statement pool
|
|
127
|
-
return await db.unsafe(statement.sql, params);
|
|
116
|
+
public async execute(
|
|
117
|
+
statement: any,
|
|
118
|
+
params: any[],
|
|
119
|
+
db: any,
|
|
120
|
+
signal?: AbortSignal,
|
|
121
|
+
perRequest?: PerRequestCounters,
|
|
122
|
+
): Promise<any[]> {
|
|
123
|
+
// Empty-string params are legitimate for text-field filters
|
|
124
|
+
// (`c.data->>'field' = ''`). UUID-typed params never reach this
|
|
125
|
+
// point empty — callers (Query.findById etc.) guard at entry. PG
|
|
126
|
+
// emits a clear error at execution time if a UUID cast meets an
|
|
127
|
+
// empty string.
|
|
128
|
+
return await timedUnsafe<any[]>(db, statement.sql, params, signal, perRequest);
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
/**
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps a Bun SQL Query so an AbortSignal can cancel the in-flight query
|
|
3
|
+
* via the underlying `query.cancel()` method. When the signal fires the
|
|
4
|
+
* server-side query receives a cancel request, the awaited promise rejects,
|
|
5
|
+
* any enclosing transaction triggers ROLLBACK, and the pooled backend
|
|
6
|
+
* connection is released. Without this, a wall-clock timeout leaks the
|
|
7
|
+
* backend into `idle in transaction` under pgbouncer transaction-mode.
|
|
8
|
+
*/
|
|
9
|
+
export async function runWithSignal<T>(q: any, signal?: AbortSignal): Promise<T> {
|
|
10
|
+
if (!signal) return await q;
|
|
11
|
+
if (signal.aborted) {
|
|
12
|
+
try { q.cancel?.(); } catch { /* ignore */ }
|
|
13
|
+
throw signal.reason ?? new Error('Query aborted');
|
|
14
|
+
}
|
|
15
|
+
const onAbort = () => { try { q.cancel?.(); } catch { /* ignore */ } };
|
|
16
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
17
|
+
try {
|
|
18
|
+
return await q;
|
|
19
|
+
} finally {
|
|
20
|
+
signal.removeEventListener('abort', onAbort);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { logger as MainLogger } from "../core/Logger";
|
|
3
|
+
import { runWithSignal } from "./cancellable";
|
|
4
|
+
|
|
5
|
+
const logger = MainLogger.child({ scope: "db" });
|
|
6
|
+
|
|
7
|
+
const SLOW_MS = parseInt(process.env.BUNSANE_DB_SLOW_MS ?? '500', 10);
|
|
8
|
+
|
|
9
|
+
export type DataLoaderKind = 'entity' | 'component' | 'relation';
|
|
10
|
+
|
|
11
|
+
interface DbStatsInternal {
|
|
12
|
+
totalCount: number;
|
|
13
|
+
totalMs: number;
|
|
14
|
+
maxMs: number;
|
|
15
|
+
slowCount: number;
|
|
16
|
+
abortedCount: number;
|
|
17
|
+
inFlight: number;
|
|
18
|
+
inFlightMax: number;
|
|
19
|
+
dataLoaderCalls: { entity: number; component: number; relation: number };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const stats: DbStatsInternal = {
|
|
23
|
+
totalCount: 0,
|
|
24
|
+
totalMs: 0,
|
|
25
|
+
maxMs: 0,
|
|
26
|
+
slowCount: 0,
|
|
27
|
+
abortedCount: 0,
|
|
28
|
+
inFlight: 0,
|
|
29
|
+
inFlightMax: 0,
|
|
30
|
+
dataLoaderCalls: { entity: 0, component: 0, relation: 0 },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Per-request counter incremented when current request context is reachable
|
|
35
|
+
* via the (request as any).__bunsaneStats pointer. We accept that as a
|
|
36
|
+
* parameter from the call site so this module stays free of GraphQL imports.
|
|
37
|
+
*/
|
|
38
|
+
export interface PerRequestCounters {
|
|
39
|
+
dbQueryCount: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Execute `db.unsafe(sql, params)` with optional AbortSignal cancellation
|
|
44
|
+
* and roundtrip telemetry. On abort the in-flight query is cancelled via
|
|
45
|
+
* `Query.cancel()`. Total ms is recorded into module-level stats; calls
|
|
46
|
+
* over `BUNSANE_DB_SLOW_MS` increment slowCount and emit a warn log.
|
|
47
|
+
*/
|
|
48
|
+
export async function timedUnsafe<T = any>(
|
|
49
|
+
db: SQL,
|
|
50
|
+
sql: string,
|
|
51
|
+
params: any[],
|
|
52
|
+
signal?: AbortSignal,
|
|
53
|
+
perRequest?: PerRequestCounters,
|
|
54
|
+
): Promise<T> {
|
|
55
|
+
const t0 = performance.now();
|
|
56
|
+
stats.inFlight++;
|
|
57
|
+
if (stats.inFlight > stats.inFlightMax) stats.inFlightMax = stats.inFlight;
|
|
58
|
+
if (perRequest) perRequest.dbQueryCount++;
|
|
59
|
+
let aborted = false;
|
|
60
|
+
try {
|
|
61
|
+
const q = (db as any).unsafe(sql, params);
|
|
62
|
+
return await runWithSignal<T>(q, signal);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if ((err as Error)?.name === 'AbortError' || signal?.aborted) {
|
|
65
|
+
aborted = true;
|
|
66
|
+
stats.abortedCount++;
|
|
67
|
+
}
|
|
68
|
+
throw err;
|
|
69
|
+
} finally {
|
|
70
|
+
const dt = performance.now() - t0;
|
|
71
|
+
stats.inFlight--;
|
|
72
|
+
stats.totalCount++;
|
|
73
|
+
stats.totalMs += dt;
|
|
74
|
+
if (dt > stats.maxMs) stats.maxMs = dt;
|
|
75
|
+
if (SLOW_MS > 0 && dt > SLOW_MS && !aborted) {
|
|
76
|
+
stats.slowCount++;
|
|
77
|
+
logger.warn(
|
|
78
|
+
{
|
|
79
|
+
durationMs: Math.round(dt),
|
|
80
|
+
thresholdMs: SLOW_MS,
|
|
81
|
+
sqlSnippet: sql.length > 200 ? sql.slice(0, 200) + '…' : sql,
|
|
82
|
+
msg: 'Slow DB call',
|
|
83
|
+
},
|
|
84
|
+
'Slow DB call',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Increment the per-kind DataLoader counter. Called from inside DataLoader
|
|
92
|
+
* batch functions so /metrics + access log can attribute load patterns.
|
|
93
|
+
*
|
|
94
|
+
* `perRequest` is loosely typed because RequestContext's `RequestStats`
|
|
95
|
+
* (defined in core/RequestContext.ts) extends `PerRequestCounters` with
|
|
96
|
+
* extra fields like `dataLoaderCalls`. We accept either shape here without
|
|
97
|
+
* importing the higher-level type (which would create a cycle).
|
|
98
|
+
*/
|
|
99
|
+
export function incrementDataLoaderCall(
|
|
100
|
+
kind: DataLoaderKind,
|
|
101
|
+
perRequest?: PerRequestCounters | { dataLoaderCalls?: { entity: number; component: number; relation: number } },
|
|
102
|
+
): void {
|
|
103
|
+
stats.dataLoaderCalls[kind]++;
|
|
104
|
+
const dlc = (perRequest as any)?.dataLoaderCalls;
|
|
105
|
+
if (dlc) dlc[kind]++;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Snapshot of accumulated DB stats for the /metrics endpoint.
|
|
110
|
+
*/
|
|
111
|
+
export function getDbStats() {
|
|
112
|
+
const avgMs = stats.totalCount > 0 ? stats.totalMs / stats.totalCount : 0;
|
|
113
|
+
return {
|
|
114
|
+
totalCount: stats.totalCount,
|
|
115
|
+
totalMs: Math.round(stats.totalMs),
|
|
116
|
+
maxMs: Math.round(stats.maxMs),
|
|
117
|
+
avgMs: Number(avgMs.toFixed(2)),
|
|
118
|
+
slowCount: stats.slowCount,
|
|
119
|
+
abortedCount: stats.abortedCount,
|
|
120
|
+
inFlight: stats.inFlight,
|
|
121
|
+
inFlightMax: stats.inFlightMax,
|
|
122
|
+
slowThresholdMs: SLOW_MS,
|
|
123
|
+
dataLoaderCalls: { ...stats.dataLoaderCalls },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Reset counters. Intended for tests only.
|
|
129
|
+
*/
|
|
130
|
+
export function resetDbStats(): void {
|
|
131
|
+
stats.totalCount = 0;
|
|
132
|
+
stats.totalMs = 0;
|
|
133
|
+
stats.maxMs = 0;
|
|
134
|
+
stats.slowCount = 0;
|
|
135
|
+
stats.abortedCount = 0;
|
|
136
|
+
stats.inFlight = 0;
|
|
137
|
+
stats.inFlightMax = 0;
|
|
138
|
+
stats.dataLoaderCalls.entity = 0;
|
|
139
|
+
stats.dataLoaderCalls.component = 0;
|
|
140
|
+
stats.dataLoaderCalls.relation = 0;
|
|
141
|
+
}
|