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
@@ -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<(ComponentData | null)[]> {
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<ComponentData>(cacheKeys);
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(components: ComponentData[], ttl?: number): Promise<void> {
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 effectiveTTL = ttl ?? this.config.component?.ttl;
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: effectiveTTL
378
+ ttl: componentTTL,
337
379
  }));
338
- await this.provider.setMany(entries);
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 (propMeta?.propertyType === Date && value instanceof Date) {
59
- value = value.toISOString();
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 logData = {
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(statement: any, params: any[], db: any): Promise<any[]> {
114
- // Validate params to catch empty strings that would cause UUID parsing errors
115
- for (let i = 0; i < params.length; i++) {
116
- const param = params[i];
117
- if (param === '' || (typeof param === 'string' && param.trim() === '')) {
118
- logger.error(`[PreparedStatementCache] Empty string parameter at position ${i + 1}`);
119
- logger.error(`[PreparedStatementCache] SQL: ${statement.sql}`);
120
- logger.error(`[PreparedStatementCache] All params: ${JSON.stringify(params)}`);
121
- throw new Error(`PreparedStatementCache.execute: Parameter $${i + 1} is an empty string. SQL: ${statement.sql.substring(0, 100)}...`);
122
- }
123
- }
124
-
125
- // For Bun's SQL, we still use db.unsafe() but with the prepared statement concept
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
+ }