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.
- package/.claude/scheduled_tasks.lock +1 -0
- package/CHANGELOG.md +52 -0
- package/config/cache.config.ts +35 -1
- package/core/App.ts +24 -1064
- package/core/ArcheType.ts +78 -2110
- package/core/Entity.ts +10 -33
- package/core/RequestContext.ts +85 -36
- package/core/RequestLoaders.ts +89 -31
- 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 +126 -9
- package/core/middleware/AccessLog.ts +8 -1
- package/database/PreparedStatementCache.ts +12 -3
- 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/Query.ts +53 -20
- 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 +132 -1
- package/tests/unit/database/cancellable.test.ts +81 -0
- 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
|
-
//
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
//
|
|
770
|
-
//
|
|
771
|
-
//
|
|
772
|
-
|
|
773
|
-
|
|
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 =
|
|
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) => {
|
package/core/RequestContext.ts
CHANGED
|
@@ -1,36 +1,85 @@
|
|
|
1
|
-
import type { Plugin } from 'graphql-yoga';
|
|
2
|
-
import { createRequestLoaders } from './RequestLoaders';
|
|
3
|
-
import type { RequestLoaders } from './RequestLoaders';
|
|
4
|
-
import db from '../database';
|
|
5
|
-
import { CacheManager } from './cache/CacheManager';
|
|
6
|
-
import { getRequestId } from './middleware/RequestId';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
1
|
+
import type { Plugin } from 'graphql-yoga';
|
|
2
|
+
import { createRequestLoaders } from './RequestLoaders';
|
|
3
|
+
import type { RequestLoaders } from './RequestLoaders';
|
|
4
|
+
import db from '../database';
|
|
5
|
+
import { CacheManager } from './cache/CacheManager';
|
|
6
|
+
import { getRequestId } from './middleware/RequestId';
|
|
7
|
+
|
|
8
|
+
export interface RequestStats {
|
|
9
|
+
operationName: string;
|
|
10
|
+
dataLoaderCalls: { entity: number; component: number; relation: number };
|
|
11
|
+
dbQueryCount: number;
|
|
12
|
+
startTime: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
declare module 'graphql-yoga' {
|
|
16
|
+
interface Context {
|
|
17
|
+
// Loaders mounted at top-level context for ArcheType resolver access
|
|
18
|
+
loaders: RequestLoaders;
|
|
19
|
+
requestId: string;
|
|
20
|
+
cacheManager: CacheManager;
|
|
21
|
+
requestStats: RequestStats;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* GraphQL Yoga plugin that creates per-request DataLoaders for batching.
|
|
28
|
+
*
|
|
29
|
+
* IMPORTANT: Loaders are mounted at context.loaders (NOT context.locals.loaders)
|
|
30
|
+
* to match what ArcheType.ts resolvers expect. This enables DataLoader batching
|
|
31
|
+
* for BelongsTo/HasMany relations, preventing N+1 queries.
|
|
32
|
+
*
|
|
33
|
+
* Also threads the request `AbortSignal` into Query/DataLoader DB calls so
|
|
34
|
+
* the framework's wall-clock timeout (handled in core/app/requestRouter.ts)
|
|
35
|
+
* cancels in-flight Postgres queries via Bun's `Query.cancel()`. Without
|
|
36
|
+
* this, an aborted request leaks its backend connection into
|
|
37
|
+
* `idle in transaction` under pgbouncer transaction-mode pooling.
|
|
38
|
+
*
|
|
39
|
+
* Captures per-request stats (operationName, DataLoader call counts,
|
|
40
|
+
* dbQueryCount) and attaches them to the underlying Request via
|
|
41
|
+
* `__bunsaneStats` so the HTTP router's catch handler + AccessLog
|
|
42
|
+
* middleware can read them after the GraphQL pipeline rejects.
|
|
43
|
+
*/
|
|
44
|
+
export function createRequestContextPlugin(): Plugin {
|
|
45
|
+
return {
|
|
46
|
+
onExecute: ({ args }) => {
|
|
47
|
+
const cacheManager = CacheManager.getInstance();
|
|
48
|
+
const ctx: any = (args as any).contextValue;
|
|
49
|
+
const request: Request | undefined = ctx?.request;
|
|
50
|
+
const signal: AbortSignal | undefined = request?.signal;
|
|
51
|
+
|
|
52
|
+
// GraphQL operation name. Falls back to first named operation in the
|
|
53
|
+
// document, or 'anonymous' if the client supplied an inline query
|
|
54
|
+
// with no name.
|
|
55
|
+
const operationName: string =
|
|
56
|
+
(typeof args.operationName === 'string' && args.operationName)
|
|
57
|
+
|| (args.document?.definitions?.find?.(
|
|
58
|
+
(d: any) => d?.kind === 'OperationDefinition' && d?.name?.value,
|
|
59
|
+
) as any)?.name?.value
|
|
60
|
+
|| 'anonymous';
|
|
61
|
+
|
|
62
|
+
const stats: RequestStats = {
|
|
63
|
+
operationName,
|
|
64
|
+
dataLoaderCalls: { entity: 0, component: 0, relation: 0 },
|
|
65
|
+
dbQueryCount: 0,
|
|
66
|
+
startTime: performance.now(),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Mount loaders at context.loaders to match ArcheType.ts resolver access pattern.
|
|
70
|
+
ctx.loaders = createRequestLoaders(db, cacheManager, signal, stats);
|
|
71
|
+
// Prefer the HTTP-layer request id (from requestId() middleware's
|
|
72
|
+
// AsyncLocalStorage) so access log + GraphQL logs share the same id.
|
|
73
|
+
ctx.requestId = getRequestId() ?? crypto.randomUUID();
|
|
74
|
+
ctx.cacheManager = cacheManager;
|
|
75
|
+
ctx.requestStats = stats;
|
|
76
|
+
ctx.signal = signal;
|
|
77
|
+
|
|
78
|
+
// Attach to the raw Request so the HTTP router catch block + access
|
|
79
|
+
// log middleware can read stats after Yoga rejects.
|
|
80
|
+
if (request) {
|
|
81
|
+
(request as any).__bunsaneStats = stats;
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
package/core/RequestLoaders.ts
CHANGED
|
@@ -2,10 +2,12 @@ import DataLoader from 'dataloader';
|
|
|
2
2
|
import { Entity } from './Entity';
|
|
3
3
|
import db from '../database';
|
|
4
4
|
import { inList } from '../database/sqlHelpers';
|
|
5
|
+
import { timedUnsafe, incrementDataLoaderCall, type PerRequestCounters } from '../database/instrumentedDb';
|
|
5
6
|
import {logger as MainLogger} from './Logger';
|
|
6
7
|
const logger = MainLogger.child({ module: 'RequestLoaders' });
|
|
7
8
|
import { getMetadataStorage } from './metadata';
|
|
8
9
|
import type { CacheManager } from './cache/CacheManager';
|
|
10
|
+
import { COMPONENT_TOMBSTONE } from './cache/CacheManager';
|
|
9
11
|
|
|
10
12
|
export type ComponentData = {
|
|
11
13
|
id: string; // Component ID for updates
|
|
@@ -23,8 +25,14 @@ export type RequestLoaders = {
|
|
|
23
25
|
relationsByEntityField: DataLoader<{ entityId: string; relationField: string; relatedType: string; foreignKey?: string }, Entity[]>;
|
|
24
26
|
};
|
|
25
27
|
|
|
26
|
-
export function createRequestLoaders(
|
|
28
|
+
export function createRequestLoaders(
|
|
29
|
+
db: any,
|
|
30
|
+
cacheManager?: CacheManager,
|
|
31
|
+
signal?: AbortSignal,
|
|
32
|
+
perRequest?: PerRequestCounters,
|
|
33
|
+
): RequestLoaders {
|
|
27
34
|
const entityById = new DataLoader<string, Entity | null>(async (ids: readonly string[]) => {
|
|
35
|
+
incrementDataLoaderCall('entity', perRequest);
|
|
28
36
|
const startTime = Date.now();
|
|
29
37
|
try {
|
|
30
38
|
// Filter out empty/invalid IDs to prevent PostgreSQL UUID parsing errors
|
|
@@ -44,12 +52,12 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
44
52
|
|
|
45
53
|
if (missingIds.length > 0) {
|
|
46
54
|
const idList = inList(missingIds, 1);
|
|
47
|
-
const rows = await db
|
|
55
|
+
const rows = await timedUnsafe<any[]>(db, `
|
|
48
56
|
SELECT id
|
|
49
57
|
FROM entities
|
|
50
58
|
WHERE id IN ${idList.sql}
|
|
51
59
|
AND deleted_at IS NULL
|
|
52
|
-
`, idList.params);
|
|
60
|
+
`, idList.params, signal, perRequest);
|
|
53
61
|
|
|
54
62
|
const entities = rows.map((row: any) => {
|
|
55
63
|
const entity = new Entity(row.id);
|
|
@@ -89,6 +97,7 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
89
97
|
|
|
90
98
|
const componentsByEntityType = new DataLoader<{ entityId: string; typeId: string }, ComponentData | null>(
|
|
91
99
|
async (keys: readonly { entityId: string; typeId: string }[]) => {
|
|
100
|
+
incrementDataLoaderCall('component', perRequest);
|
|
92
101
|
const startTime = Date.now();
|
|
93
102
|
try {
|
|
94
103
|
// Filter out keys with empty/invalid entity IDs to prevent PostgreSQL UUID parsing errors
|
|
@@ -99,16 +108,20 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
99
108
|
|
|
100
109
|
const results = new Map<string, ComponentData | null>();
|
|
101
110
|
|
|
102
|
-
// Check cache first if cache manager is available
|
|
111
|
+
// Check cache first if cache manager is available. Tombstone hits
|
|
112
|
+
// are recorded as null in `results` so the DB-fetch step skips them.
|
|
103
113
|
let cacheHits = 0;
|
|
104
114
|
let cacheMisses = 0;
|
|
105
115
|
if (cacheManager && cacheManager.getConfig().enabled && cacheManager.getConfig().component?.enabled) {
|
|
106
116
|
try {
|
|
107
117
|
const cachedComponents = await cacheManager.getComponents(validKeys);
|
|
108
|
-
cachedComponents.forEach((
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
results.set(key,
|
|
118
|
+
cachedComponents.forEach((value, index) => {
|
|
119
|
+
const key = `${validKeys[index]!.entityId}-${validKeys[index]!.typeId}`;
|
|
120
|
+
if (value === COMPONENT_TOMBSTONE) {
|
|
121
|
+
results.set(key, null);
|
|
122
|
+
cacheHits++;
|
|
123
|
+
} else if (value) {
|
|
124
|
+
results.set(key, value);
|
|
112
125
|
cacheHits++;
|
|
113
126
|
} else {
|
|
114
127
|
cacheMisses++;
|
|
@@ -122,17 +135,16 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
122
135
|
cacheMisses += validKeys.length;
|
|
123
136
|
}
|
|
124
137
|
|
|
125
|
-
// Log cache hit/miss rates for monitoring
|
|
126
138
|
if (validKeys.length > 0) {
|
|
127
139
|
const hitRate = (cacheHits / validKeys.length) * 100;
|
|
128
|
-
logger.
|
|
129
|
-
scope: 'cache',
|
|
130
|
-
component: 'RequestLoaders',
|
|
131
|
-
msg: 'Component cache statistics',
|
|
132
|
-
total: validKeys.length,
|
|
133
|
-
hits: cacheHits,
|
|
134
|
-
misses: cacheMisses,
|
|
135
|
-
hitRate: `${hitRate.toFixed(1)}
|
|
140
|
+
logger.trace({
|
|
141
|
+
scope: 'cache',
|
|
142
|
+
component: 'RequestLoaders',
|
|
143
|
+
msg: 'Component cache statistics',
|
|
144
|
+
total: validKeys.length,
|
|
145
|
+
hits: cacheHits,
|
|
146
|
+
misses: cacheMisses,
|
|
147
|
+
hitRate: `${hitRate.toFixed(1)}%`,
|
|
136
148
|
});
|
|
137
149
|
}
|
|
138
150
|
|
|
@@ -144,13 +156,13 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
144
156
|
const typeIds = [...new Set(missingKeys.map(k => k.typeId))];
|
|
145
157
|
const entityIdList = inList(entityIds, 1);
|
|
146
158
|
const typeIdList = inList(typeIds, entityIdList.newParamIndex);
|
|
147
|
-
const rows = await db
|
|
159
|
+
const rows = await timedUnsafe<any[]>(db, `
|
|
148
160
|
SELECT id, entity_id, type_id, data, created_at, updated_at, deleted_at
|
|
149
161
|
FROM components
|
|
150
162
|
WHERE entity_id IN ${entityIdList.sql}
|
|
151
163
|
AND type_id IN ${typeIdList.sql}
|
|
152
164
|
AND deleted_at IS NULL
|
|
153
|
-
`, [...entityIdList.params, ...typeIdList.params]);
|
|
165
|
+
`, [...entityIdList.params, ...typeIdList.params], signal, perRequest);
|
|
154
166
|
|
|
155
167
|
const components: ComponentData[] = rows.map((row: any) => ({
|
|
156
168
|
id: row.id,
|
|
@@ -162,10 +174,15 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
162
174
|
deletedAt: row.deleted_at,
|
|
163
175
|
}));
|
|
164
176
|
|
|
165
|
-
// Cache the loaded components
|
|
177
|
+
// Cache the loaded components + tombstone any requested keys whose
|
|
178
|
+
// row was absent (single setMany — see CacheManager.setComponentsWriteThrough).
|
|
166
179
|
if (cacheManager && cacheManager.getConfig().enabled && cacheManager.getConfig().component?.enabled) {
|
|
167
180
|
try {
|
|
168
|
-
await cacheManager.setComponentsWriteThrough(
|
|
181
|
+
await cacheManager.setComponentsWriteThrough(
|
|
182
|
+
components,
|
|
183
|
+
missingKeys,
|
|
184
|
+
cacheManager.getConfig().component!.ttl,
|
|
185
|
+
);
|
|
169
186
|
} catch (error: any) {
|
|
170
187
|
logger.warn({ scope: 'cache', component: 'RequestLoaders', msg: 'Cache write failed for components', error });
|
|
171
188
|
}
|
|
@@ -199,6 +216,7 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
199
216
|
|
|
200
217
|
const relationsByEntityField = new DataLoader<{ entityId: string; relationField: string; relatedType: string; foreignKey?: string }, Entity[]>(
|
|
201
218
|
async (keys: readonly { entityId: string; relationField: string; relatedType: string; foreignKey?: string }[]) => {
|
|
219
|
+
incrementDataLoaderCall('relation', perRequest);
|
|
202
220
|
const startTime = Date.now();
|
|
203
221
|
try {
|
|
204
222
|
// Filter valid keys
|
|
@@ -207,9 +225,35 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
207
225
|
return keys.map(() => []);
|
|
208
226
|
}
|
|
209
227
|
|
|
228
|
+
const resultMap = new Map<string, Entity[]>();
|
|
229
|
+
|
|
230
|
+
// Negative-cache lookup: skip DB for keys recorded as empty.
|
|
231
|
+
let keysToQuery = validKeys;
|
|
232
|
+
const relCacheEnabled = !!(cacheManager
|
|
233
|
+
&& cacheManager.getConfig().enabled
|
|
234
|
+
&& cacheManager.getConfig().relation?.negativeCacheEnabled);
|
|
235
|
+
if (relCacheEnabled) {
|
|
236
|
+
try {
|
|
237
|
+
const tombstones = await cacheManager!.getRelationsEmpty(validKeys);
|
|
238
|
+
const remaining: typeof validKeys = [];
|
|
239
|
+
tombstones.forEach((isEmpty, i) => {
|
|
240
|
+
const k = validKeys[i]!;
|
|
241
|
+
if (isEmpty) {
|
|
242
|
+
const mapKey = `${k.entityId}\x00${k.relationField}\x00${k.relatedType}`;
|
|
243
|
+
resultMap.set(mapKey, []);
|
|
244
|
+
} else {
|
|
245
|
+
remaining.push(k);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
keysToQuery = remaining;
|
|
249
|
+
} catch (error) {
|
|
250
|
+
logger.warn({ scope: 'cache', component: 'RequestLoaders', msg: 'Cache read failed for relation tombstones', error });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
210
254
|
// Group keys by foreign key for efficient batching
|
|
211
|
-
const keysByForeignKey = new Map<string, typeof
|
|
212
|
-
for (const key of
|
|
255
|
+
const keysByForeignKey = new Map<string, typeof keysToQuery>();
|
|
256
|
+
for (const key of keysToQuery) {
|
|
213
257
|
const fk = key.foreignKey || 'default';
|
|
214
258
|
if (!keysByForeignKey.has(fk)) {
|
|
215
259
|
keysByForeignKey.set(fk, []);
|
|
@@ -217,8 +261,6 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
217
261
|
keysByForeignKey.get(fk)!.push(key);
|
|
218
262
|
}
|
|
219
263
|
|
|
220
|
-
const resultMap = new Map<string, Entity[]>();
|
|
221
|
-
|
|
222
264
|
// OPTIMIZED: Batch query for each foreign key type (instead of N separate queries)
|
|
223
265
|
for (const [foreignKey, groupedKeys] of keysByForeignKey) {
|
|
224
266
|
const entityIds = [...new Set(groupedKeys.map(k => k.entityId))];
|
|
@@ -240,19 +282,19 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
240
282
|
logger.trace(`[RelationLoader] Batched query for ${groupedKeys.length} keys with foreign key ${foreignKey}`);
|
|
241
283
|
|
|
242
284
|
// SINGLE BATCHED QUERY for all entities in this group
|
|
243
|
-
const rows = await db
|
|
244
|
-
SELECT DISTINCT
|
|
245
|
-
c.entity_id,
|
|
246
|
-
c.data,
|
|
285
|
+
const rows = await timedUnsafe<any[]>(db, `
|
|
286
|
+
SELECT DISTINCT
|
|
287
|
+
c.entity_id,
|
|
288
|
+
c.data,
|
|
247
289
|
c.type_id,
|
|
248
290
|
c.data->>'${foreignKeyField}' as fk_value,
|
|
249
291
|
COALESCE(c.data->>'user_id', c.data->>'parent_id') as fallback_fk_value
|
|
250
292
|
FROM components c
|
|
251
293
|
INNER JOIN entities e ON c.entity_id = e.id
|
|
252
|
-
WHERE e.deleted_at IS NULL
|
|
294
|
+
WHERE e.deleted_at IS NULL
|
|
253
295
|
AND c.deleted_at IS NULL
|
|
254
296
|
AND ${whereClause}
|
|
255
|
-
`, [entityIds]);
|
|
297
|
+
`, [entityIds], signal, perRequest);
|
|
256
298
|
|
|
257
299
|
logger.trace(`[RelationLoader] Found ${rows.length} total components for ${entityIds.length} entities`);
|
|
258
300
|
|
|
@@ -281,6 +323,22 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
281
323
|
}
|
|
282
324
|
}
|
|
283
325
|
|
|
326
|
+
// Write tombstones for queried keys whose result was empty.
|
|
327
|
+
if (relCacheEnabled && keysToQuery.length > 0) {
|
|
328
|
+
const emptyKeys = keysToQuery.filter(k => {
|
|
329
|
+
const mapKey = `${k.entityId}\x00${k.relationField}\x00${k.relatedType}`;
|
|
330
|
+
const r = resultMap.get(mapKey);
|
|
331
|
+
return !r || r.length === 0;
|
|
332
|
+
});
|
|
333
|
+
if (emptyKeys.length > 0) {
|
|
334
|
+
try {
|
|
335
|
+
await cacheManager!.setRelationsEmpty(emptyKeys);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
logger.warn({ scope: 'cache', component: 'RequestLoaders', msg: 'Cache write failed for relation tombstones', error });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
284
342
|
const duration = Date.now() - startTime;
|
|
285
343
|
if (duration > 1000) {
|
|
286
344
|
logger.warn(`Slow relationsByEntityField query: ${duration}ms for ${keys.length} keys`);
|
|
@@ -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
|
+
}
|
package/core/app/cors.ts
ADDED
|
@@ -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
|
+
}
|