bunsane 0.3.1 → 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 (41) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/CHANGELOG.md +52 -0
  3. package/config/cache.config.ts +35 -1
  4. package/core/App.ts +24 -1064
  5. package/core/ArcheType.ts +78 -2110
  6. package/core/Entity.ts +10 -33
  7. package/core/RequestContext.ts +85 -36
  8. package/core/RequestLoaders.ts +89 -31
  9. package/core/app/bootstrap.ts +133 -0
  10. package/core/app/cors.ts +94 -0
  11. package/core/app/graphqlSetup.ts +56 -0
  12. package/core/app/healthEndpoints.ts +31 -0
  13. package/core/app/metricsCollector.ts +27 -0
  14. package/core/app/preparedStatementWarmup.ts +55 -0
  15. package/core/app/processHandlers.ts +43 -0
  16. package/core/app/requestRouter.ts +309 -0
  17. package/core/app/restRegistry.ts +72 -0
  18. package/core/app/shutdown.ts +97 -0
  19. package/core/app/studioRouter.ts +83 -0
  20. package/core/archetype/customTypes.ts +100 -0
  21. package/core/archetype/decorators.ts +171 -0
  22. package/core/archetype/fieldResolvers.ts +621 -0
  23. package/core/archetype/helpers.ts +29 -0
  24. package/core/archetype/relationLoader.ts +118 -0
  25. package/core/archetype/schemaBuilder.ts +141 -0
  26. package/core/archetype/weaver.ts +218 -0
  27. package/core/archetype/zodSchemaBuilder.ts +527 -0
  28. package/core/cache/CacheManager.ts +126 -9
  29. package/core/middleware/AccessLog.ts +8 -1
  30. package/database/PreparedStatementCache.ts +12 -3
  31. package/database/cancellable.ts +22 -0
  32. package/database/instrumentedDb.ts +141 -0
  33. package/docs/RFC_APP_REFACTOR.md +248 -0
  34. package/docs/RFC_REFACTOR_TARGETS.md +251 -0
  35. package/package.json +1 -1
  36. package/query/Query.ts +53 -20
  37. package/tests/integration/loaders/RequestLoaders.abort.test.ts +82 -0
  38. package/tests/integration/query/Query.abort.test.ts +66 -0
  39. package/tests/unit/cache/CacheManager.test.ts +132 -1
  40. package/tests/unit/database/cancellable.test.ts +81 -0
  41. package/tests/unit/database/instrumentedDb.test.ts +160 -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";
@@ -763,26 +764,14 @@ export class Entity implements IEntity {
763
764
  return true;
764
765
  }
765
766
 
766
- // Execute a Bun SQL query with AbortSignal support. On abort, the
767
- // in-flight query is cancelled (SQL.Query.cancel()) which causes the
768
- // transaction callback to throw, triggering Bun's automatic ROLLBACK
769
- // and releasing the pooled backend connection. Without this, a wall-
770
- // clock timeout leaks backends into `idle in transaction` state —
771
- // fatal under pgbouncer transaction-mode pooling.
772
- const run = async <T>(q: any): Promise<T> => {
773
- if (!signal) return await q;
774
- if (signal.aborted) {
775
- try { q.cancel?.(); } catch { /* ignore */ }
776
- throw signal.reason ?? new Error('Entity.save aborted');
777
- }
778
- const onAbort = () => { try { q.cancel?.(); } catch { /* ignore */ } };
779
- signal.addEventListener('abort', onAbort, { once: true });
780
- try {
781
- return await q;
782
- } finally {
783
- signal.removeEventListener('abort', onAbort);
784
- }
785
- };
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);
786
775
 
787
776
  const executeSave = async (saveTrx: SQL) => {
788
777
  if (!this._persisted) {
@@ -917,19 +906,7 @@ export class Entity implements IEntity {
917
906
  }, timeoutMs);
918
907
 
919
908
  const signal = controller.signal;
920
- const run = async <T>(q: any): Promise<T> => {
921
- if (signal.aborted) {
922
- try { q.cancel?.(); } catch { /* ignore */ }
923
- throw signal.reason ?? new Error('Entity.doDelete aborted');
924
- }
925
- const onAbort = () => { try { q.cancel?.(); } catch { /* ignore */ } };
926
- signal.addEventListener('abort', onAbort, { once: true });
927
- try {
928
- return await q;
929
- } finally {
930
- signal.removeEventListener('abort', onAbort);
931
- }
932
- };
909
+ const run = <T>(q: any): Promise<T> => runWithSignal<T>(q, signal);
933
910
 
934
911
  try {
935
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`);
@@ -0,0 +1,133 @@
1
+ import ApplicationLifecycle, {
2
+ ApplicationPhase,
3
+ type PhaseChangeEvent,
4
+ } from "../ApplicationLifecycle";
5
+ import { logger as MainLogger } from "../Logger";
6
+ import ServiceRegistry from "../../service/ServiceRegistry";
7
+ import { SchedulerManager } from "../SchedulerManager";
8
+ import { registerScheduledTasks } from "../../scheduler";
9
+ import {
10
+ RemoteManager,
11
+ registerRemoteHandlers,
12
+ setRemoteManager,
13
+ type RemoteManagerConfig,
14
+ } from "../remote";
15
+ import { setupGraphQL } from "./graphqlSetup";
16
+ import { collectRestEndpoints } from "./restRegistry";
17
+
18
+ const logger = MainLogger.child({ scope: "App" });
19
+
20
+ export function createPhaseListener(app: any): (event: PhaseChangeEvent) => Promise<void> {
21
+ return async (event: PhaseChangeEvent) => {
22
+ const phase = event.detail;
23
+ logger.info(`Application phase changed to: ${phase}`);
24
+ for (const plugin of app.plugins) {
25
+ if (plugin.onPhaseChange) {
26
+ await plugin.onPhaseChange(phase, app);
27
+ }
28
+ }
29
+ switch (phase) {
30
+ case ApplicationPhase.DATABASE_READY:
31
+ await runDatabaseReadyPhase(app);
32
+ break;
33
+ case ApplicationPhase.SYSTEM_READY:
34
+ await runSystemReadyPhase(app);
35
+ break;
36
+ case ApplicationPhase.APPLICATION_READY:
37
+ await runApplicationReadyPhase(app);
38
+ break;
39
+ }
40
+ };
41
+ }
42
+
43
+ export async function runDatabaseReadyPhase(app: any): Promise<void> {
44
+ try {
45
+ await app.warmUpPreparedStatementCache();
46
+ } catch (error) {
47
+ logger.warn("Failed to warm up prepared statement cache:", error as any);
48
+ }
49
+ }
50
+
51
+ export async function runSystemReadyPhase(app: any): Promise<void> {
52
+ try {
53
+ const { CacheManager } = await import('../cache/CacheManager');
54
+ const cacheManager = CacheManager.getInstance();
55
+ const config = cacheManager.getConfig();
56
+
57
+ if (config.enabled) {
58
+ const isHealthy = await cacheManager.getProvider().ping();
59
+ if (isHealthy) {
60
+ logger.info({ scope: 'cache', component: 'App', msg: 'Cache health check passed' });
61
+ } else {
62
+ logger.warn({ scope: 'cache', component: 'App', msg: 'Cache health check failed' });
63
+ }
64
+ }
65
+ } catch (error) {
66
+ logger.warn({ scope: 'cache', component: 'App', msg: 'Cache health check error', error });
67
+ }
68
+
69
+ try {
70
+ setupGraphQL(app);
71
+
72
+ const services = ServiceRegistry.getServices();
73
+
74
+ const scheduler = SchedulerManager.getInstance();
75
+ scheduler.config.enableLogging = app.config.scheduler.logging;
76
+
77
+ for (const service of services) {
78
+ try {
79
+ registerScheduledTasks(service);
80
+ } catch (error) {
81
+ logger.warn(`Failed to register scheduled tasks for service ${service.constructor.name}`);
82
+ logger.warn(error);
83
+ }
84
+ }
85
+ logger.info(`Registered scheduled tasks for ${services.length} services`);
86
+
87
+ if (app.remoteConfig) {
88
+ try {
89
+ const rmConfig: RemoteManagerConfig = {
90
+ appName: app.remoteConfig.appName || app.name,
91
+ ...app.remoteConfig,
92
+ };
93
+ app.remote = new RemoteManager(rmConfig);
94
+ setRemoteManager(app.remote);
95
+ await app.remote.start();
96
+
97
+ for (const service of services) {
98
+ try {
99
+ registerRemoteHandlers(service);
100
+ } catch (error) {
101
+ logger.warn(`Failed to register remote handlers for service ${service.constructor.name}`);
102
+ logger.warn(error);
103
+ }
104
+ }
105
+ logger.info(`RemoteManager initialized for app "${rmConfig.appName}"`);
106
+ } catch (error) {
107
+ logger.error("Failed to start RemoteManager:");
108
+ logger.error(error);
109
+ }
110
+ }
111
+
112
+ collectRestEndpoints(app, services);
113
+
114
+ ApplicationLifecycle.setPhase(ApplicationPhase.APPLICATION_READY);
115
+ } catch (error) {
116
+ // SYSTEM_READY failures must not be swallowed silently. Without this,
117
+ // the app stays forever in SYSTEM_READY (isReady=false,
118
+ // /health/ready → 503 forever) and k8s rollout hangs with no
119
+ // observable cause. Surface so readiness probe reports it (C09).
120
+ app.isReady = false;
121
+ logger.fatal({ scope: 'app', component: 'App', err: error }, 'Fatal error during SYSTEM_READY phase — marking app unready');
122
+ if (process.env.NODE_ENV === 'test') {
123
+ throw error;
124
+ }
125
+ setTimeout(() => process.exit(1), 100).unref?.();
126
+ }
127
+ }
128
+
129
+ export async function runApplicationReadyPhase(app: any): Promise<void> {
130
+ if (process.env.NODE_ENV !== "test") {
131
+ app.start();
132
+ }
133
+ }
@@ -0,0 +1,94 @@
1
+ import type { CorsConfig } from "../App";
2
+
3
+ export function assertValidCorsConfig(cors: CorsConfig): void {
4
+ if (cors.origin === undefined) {
5
+ throw new Error('[CORS] `origin` is required. Pass an explicit string, array, function, or "*" if you truly want to allow everyone.');
6
+ }
7
+ if (cors.credentials && cors.origin === '*') {
8
+ console.warn('[CORS] Warning: credentials=true with origin="*" is invalid per spec. Origin will be reflected from request.');
9
+ }
10
+ }
11
+
12
+ export function validateOrigin(
13
+ cors: CorsConfig | undefined,
14
+ requestOrigin: string | null | undefined,
15
+ ): string | null {
16
+ if (!cors || !requestOrigin) return null;
17
+
18
+ const configOrigin = cors.origin;
19
+
20
+ if (configOrigin === undefined) return null;
21
+
22
+ if (configOrigin === '*') {
23
+ return cors.credentials ? requestOrigin : '*';
24
+ }
25
+
26
+ if (typeof configOrigin === 'string') {
27
+ return requestOrigin === configOrigin ? configOrigin : null;
28
+ }
29
+
30
+ if (Array.isArray(configOrigin)) {
31
+ return configOrigin.includes(requestOrigin) ? requestOrigin : null;
32
+ }
33
+
34
+ if (typeof configOrigin === 'function') {
35
+ return configOrigin(requestOrigin) ? requestOrigin : null;
36
+ }
37
+
38
+ return null;
39
+ }
40
+
41
+ export function getCorsHeaders(
42
+ cors: CorsConfig | undefined,
43
+ req?: Request,
44
+ ): Record<string, string> {
45
+ if (!cors) return {};
46
+
47
+ const requestOrigin = req?.headers.get('Origin');
48
+ const allowedOrigin = validateOrigin(cors, requestOrigin);
49
+
50
+ if (requestOrigin && !allowedOrigin) return {};
51
+
52
+ const headers: Record<string, string> = {
53
+ 'Access-Control-Allow-Methods': cors.methods?.join(', ') || 'GET, POST, PUT, DELETE, OPTIONS',
54
+ 'Access-Control-Allow-Headers': cors.allowedHeaders?.join(', ') || 'Content-Type, Authorization',
55
+ 'Vary': 'Origin',
56
+ };
57
+ if (allowedOrigin) {
58
+ headers['Access-Control-Allow-Origin'] = allowedOrigin;
59
+ }
60
+
61
+ if (cors.credentials) {
62
+ headers['Access-Control-Allow-Credentials'] = 'true';
63
+ }
64
+
65
+ if (cors.exposedHeaders?.length) {
66
+ headers['Access-Control-Expose-Headers'] = cors.exposedHeaders.join(', ');
67
+ }
68
+
69
+ if (cors.maxAge !== undefined) {
70
+ headers['Access-Control-Max-Age'] = String(cors.maxAge);
71
+ }
72
+
73
+ return headers;
74
+ }
75
+
76
+ export function addCorsHeaders(
77
+ response: Response,
78
+ cors: CorsConfig | undefined,
79
+ req?: Request,
80
+ ): Response {
81
+ const corsHeaders = getCorsHeaders(cors, req);
82
+ if (Object.keys(corsHeaders).length === 0) return response;
83
+
84
+ const newHeaders = new Headers(response.headers);
85
+ for (const [key, value] of Object.entries(corsHeaders)) {
86
+ newHeaders.set(key, value);
87
+ }
88
+
89
+ return new Response(response.body, {
90
+ status: response.status,
91
+ statusText: response.statusText,
92
+ headers: newHeaders,
93
+ });
94
+ }