bunsane 0.3.2 → 0.4.0
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/CHANGELOG.md +445 -370
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +85 -1020
- package/core/EntityHookManager.ts +52 -754
- package/core/Logger.ts +10 -0
- package/core/RequestContext.ts +94 -85
- package/core/RequestLoaders.ts +98 -5
- package/core/SchedulerManager.ts +28 -600
- package/core/app/cors.ts +2 -11
- package/core/app/preparedStatementWarmup.ts +9 -49
- package/core/app/requestRouter.ts +9 -8
- package/core/app/restRegistry.ts +8 -0
- package/core/archetype/fieldResolvers.ts +85 -40
- package/core/archetype/relationLoader.ts +135 -92
- package/core/cache/CacheManager.ts +91 -302
- package/core/cache/CompressionUtils.ts +34 -3
- package/core/cache/MemoryCache.ts +40 -37
- package/core/cache/RedisCache.ts +4 -4
- package/core/cache/health.ts +30 -0
- package/core/cache/invalidation.ts +96 -0
- package/core/cache/strategies/writeInvalidate.ts +111 -0
- package/core/cache/strategies/writeThrough.ts +233 -0
- package/core/components/BaseComponent.ts +16 -8
- package/core/components/ComponentRegistry.ts +28 -0
- package/core/decorators/IndexedField.ts +1 -1
- package/core/entity/cacheStrategies.ts +97 -0
- package/core/entity/componentAccess.ts +364 -0
- package/core/entity/finders.ts +202 -0
- package/core/entity/pendingOps.ts +72 -0
- package/core/entity/saveEntity.ts +377 -0
- package/core/hooks/dispatcher.ts +439 -0
- package/core/hooks/guards.ts +155 -0
- package/core/hooks/registry.ts +247 -0
- package/core/metadata/definitions/Component.ts +1 -1
- package/core/metadata/index.ts +15 -4
- package/core/middleware/RateLimit.ts +102 -105
- package/core/middleware/RequestId.ts +2 -9
- package/core/middleware/SecurityHeaders.ts +2 -11
- package/core/middleware/headers.ts +28 -0
- package/core/remote/OutboxWorker.ts +213 -183
- package/core/remote/RemoteManager.ts +401 -400
- package/core/remote/types.ts +153 -151
- package/core/requestScope.ts +34 -0
- package/core/scheduler/cronEvaluator.ts +174 -0
- package/core/scheduler/lifecycleHooks.ts +21 -0
- package/core/scheduler/lockCoordinator.ts +27 -0
- package/core/scheduler/metrics.ts +14 -0
- package/core/scheduler/taskRunner.ts +420 -0
- package/database/DatabaseHelper.ts +128 -101
- package/database/IndexingStrategy.ts +72 -2
- package/database/PreparedStatementCache.ts +8 -2
- package/database/cancellable.ts +35 -22
- package/database/index.ts +15 -3
- package/database/instrumentedDb.ts +141 -141
- package/endpoints/archetypes.ts +2 -8
- package/endpoints/tables.ts +6 -1
- package/gql/index.ts +1 -1
- package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
- package/package.json +22 -1
- package/query/CTENode.ts +5 -3
- package/query/ComponentInclusionNode.ts +240 -13
- package/query/OrNode.ts +6 -5
- package/query/Query.ts +157 -46
- package/query/QueryContext.ts +6 -0
- package/query/QueryDAG.ts +7 -2
- package/query/membershipSource.ts +66 -0
- package/storage/LocalStorageProvider.ts +8 -3
- package/studio/dist/assets/index-BMZ67Npg.js +254 -0
- package/studio/dist/assets/index-BpbuYz9g.css +1 -0
- package/studio/{index.html → dist/index.html} +3 -2
- package/swagger/generator.ts +11 -1
- package/upload/UploadManager.ts +8 -6
- package/utils/uuid.ts +40 -10
- package/.claude/scheduled_tasks.lock +0 -1
- package/.claude/settings.local.json +0 -47
- package/.prettierrc +0 -4
- package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
- package/.serena/memories/architecture.md +0 -154
- package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
- package/.serena/memories/code_style_and_conventions.md +0 -76
- package/.serena/memories/project_overview.md +0 -43
- package/.serena/memories/schema-dsl-plan.md +0 -107
- package/.serena/memories/suggested_commands.md +0 -80
- package/.serena/memories/typescript-compilation-status.md +0 -54
- package/.serena/project.yml +0 -114
- package/BunSane.jpg +0 -0
- package/CLAUDE.md +0 -198
- package/TODO.md +0 -2
- package/bun.lock +0 -302
- package/bunfig.toml +0 -10
- package/docs/RFC_APP_REFACTOR.md +0 -248
- package/docs/RFC_REFACTOR_TARGETS.md +0 -251
- package/docs/SCALABILITY_PLAN.md +0 -175
- package/studio/bun.lock +0 -482
- package/studio/package.json +0 -39
- package/studio/postcss.config.js +0 -6
- package/studio/src/components/DataTable.tsx +0 -211
- package/studio/src/components/Layout.tsx +0 -13
- package/studio/src/components/PageContainer.tsx +0 -9
- package/studio/src/components/PageHeader.tsx +0 -13
- package/studio/src/components/SearchBar.tsx +0 -57
- package/studio/src/components/Sidebar.tsx +0 -294
- package/studio/src/components/ui/button.tsx +0 -56
- package/studio/src/components/ui/checkbox.tsx +0 -26
- package/studio/src/components/ui/input.tsx +0 -25
- package/studio/src/hooks/useDataTable.ts +0 -131
- package/studio/src/index.css +0 -36
- package/studio/src/lib/api.ts +0 -186
- package/studio/src/lib/utils.ts +0 -13
- package/studio/src/main.tsx +0 -17
- package/studio/src/pages/ArcheType.tsx +0 -239
- package/studio/src/pages/Components.tsx +0 -124
- package/studio/src/pages/EntityInspector.tsx +0 -302
- package/studio/src/pages/QueryRunner.tsx +0 -246
- package/studio/src/pages/Table.tsx +0 -94
- package/studio/src/pages/Welcome.tsx +0 -241
- package/studio/src/routes.tsx +0 -45
- package/studio/src/store/archeTypeSettings.ts +0 -30
- package/studio/src/store/studio.ts +0 -65
- package/studio/src/utils/columnHelpers.tsx +0 -114
- package/studio/studio-instructions.md +0 -81
- package/studio/tailwind.config.js +0 -77
- package/studio/utils.ts +0 -54
- package/studio/vite.config.js +0 -19
- package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
- package/tests/benchmark/bunfig.toml +0 -9
- package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
- package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
- package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
- package/tests/benchmark/fixtures/index.ts +0 -6
- package/tests/benchmark/index.ts +0 -22
- package/tests/benchmark/noop-preload.ts +0 -3
- package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
- package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
- package/tests/benchmark/runners/index.ts +0 -4
- package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
- package/tests/benchmark/scripts/generate-db.ts +0 -344
- package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
- package/tests/e2e/http.test.ts +0 -130
- package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
- package/tests/fixtures/components/TestOrder.ts +0 -23
- package/tests/fixtures/components/TestProduct.ts +0 -23
- package/tests/fixtures/components/TestUser.ts +0 -20
- package/tests/fixtures/components/index.ts +0 -6
- package/tests/graphql/SchemaGeneration.test.ts +0 -90
- package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
- package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
- package/tests/helpers/MockRedisClient.ts +0 -113
- package/tests/helpers/MockRedisStreamServer.ts +0 -448
- package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
- package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
- package/tests/integration/entity/Entity.persistence.test.ts +0 -333
- package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
- package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
- package/tests/integration/query/Query.abort.test.ts +0 -66
- package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
- package/tests/integration/query/Query.edgeCases.test.ts +0 -595
- package/tests/integration/query/Query.exec.test.ts +0 -576
- package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
- package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
- package/tests/integration/remote/dlq.test.ts +0 -175
- package/tests/integration/remote/event-dispatch.test.ts +0 -114
- package/tests/integration/remote/outbox.test.ts +0 -130
- package/tests/integration/remote/rpc.test.ts +0 -177
- package/tests/pglite-setup.ts +0 -62
- package/tests/setup.ts +0 -164
- package/tests/stress/BenchmarkRunner.ts +0 -203
- package/tests/stress/DataSeeder.ts +0 -190
- package/tests/stress/StressTestReporter.ts +0 -229
- package/tests/stress/cursor-perf-test.ts +0 -171
- package/tests/stress/fixtures/RealisticComponents.ts +0 -235
- package/tests/stress/fixtures/StressTestComponents.ts +0 -58
- package/tests/stress/index.ts +0 -7
- package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
- package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
- package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
- package/tests/unit/BatchLoader.test.ts +0 -196
- package/tests/unit/archetype/ArcheType.test.ts +0 -107
- package/tests/unit/cache/CacheManager.test.ts +0 -498
- package/tests/unit/cache/MemoryCache.test.ts +0 -260
- package/tests/unit/cache/RedisCache.test.ts +0 -411
- package/tests/unit/database/cancellable.test.ts +0 -81
- package/tests/unit/database/instrumentedDb.test.ts +0 -160
- package/tests/unit/entity/Entity.components.test.ts +0 -317
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
- package/tests/unit/entity/Entity.reload.test.ts +0 -63
- package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
- package/tests/unit/entity/Entity.test.ts +0 -345
- package/tests/unit/gql/depthLimit.test.ts +0 -203
- package/tests/unit/gql/operationMiddleware.test.ts +0 -293
- package/tests/unit/health/Health.test.ts +0 -129
- package/tests/unit/middleware/AccessLog.test.ts +0 -37
- package/tests/unit/middleware/Middleware.test.ts +0 -98
- package/tests/unit/middleware/RequestId.test.ts +0 -54
- package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
- package/tests/unit/query/FilterBuilder.test.ts +0 -111
- package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
- package/tests/unit/query/Query.emptyString.test.ts +0 -69
- package/tests/unit/query/Query.test.ts +0 -310
- package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
- package/tests/unit/remote/RemoteError.test.ts +0 -55
- package/tests/unit/remote/decorators.test.ts +0 -195
- package/tests/unit/remote/metrics.test.ts +0 -115
- package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
- package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
- package/tests/unit/schema/schema-integration.test.ts +0 -426
- package/tests/unit/schema/schema.test.ts +0 -580
- package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
- package/tests/unit/upload/RestUpload.test.ts +0 -267
- package/tests/unit/validateEnv.test.ts +0 -82
- package/tests/utils/entity-tracker.ts +0 -57
- package/tests/utils/index.ts +0 -13
- package/tests/utils/test-context.ts +0 -149
package/core/app/cors.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CorsConfig } from "../App";
|
|
2
|
+
import { setResponseHeaders } from "../middleware/headers";
|
|
2
3
|
|
|
3
4
|
export function assertValidCorsConfig(cors: CorsConfig): void {
|
|
4
5
|
if (cors.origin === undefined) {
|
|
@@ -80,15 +81,5 @@ export function addCorsHeaders(
|
|
|
80
81
|
): Response {
|
|
81
82
|
const corsHeaders = getCorsHeaders(cors, req);
|
|
82
83
|
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
|
-
});
|
|
84
|
+
return setResponseHeaders(response, Object.entries(corsHeaders));
|
|
94
85
|
}
|
|
@@ -1,55 +1,15 @@
|
|
|
1
|
-
import { ComponentRegistry } from "../components";
|
|
2
1
|
import { logger as MainLogger } from "../Logger";
|
|
3
|
-
import { preparedStatementCache } from "../../database/PreparedStatementCache";
|
|
4
|
-
import db from "../../database";
|
|
5
2
|
|
|
6
3
|
const logger = MainLogger.child({ scope: "App" });
|
|
7
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @deprecated No-op. The framework-level prepared statement cache was
|
|
7
|
+
* removed from the query hot path — Bun SQL auto-prepares parameterized
|
|
8
|
+
* statements per connection (prepare:true default), so server-side plan
|
|
9
|
+
* reuse happens at the driver layer and "warming" a placeholder map bought
|
|
10
|
+
* nothing. Kept so bootstrap's call site and the public App surface remain
|
|
11
|
+
* stable.
|
|
12
|
+
*/
|
|
8
13
|
export async function warmUpPreparedStatementCache(_app: any): Promise<void> {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (components.length === 0) {
|
|
12
|
-
logger.trace("No components registered yet, skipping cache warm-up");
|
|
13
|
-
return;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const commonQueries: Array<{ sql: string; key: string }> = [];
|
|
17
|
-
|
|
18
|
-
commonQueries.push({
|
|
19
|
-
sql: "SELECT COUNT(*) as count FROM (SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.deleted_at IS NULL) AS subquery",
|
|
20
|
-
key: "count_all_entities",
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
for (let i = 0; i < Math.min(5, components.length); i++) {
|
|
24
|
-
const component = components[i];
|
|
25
|
-
if (component) {
|
|
26
|
-
const { name } = component;
|
|
27
|
-
const typeId = ComponentRegistry.getComponentId(name);
|
|
28
|
-
if (typeId) {
|
|
29
|
-
commonQueries.push({
|
|
30
|
-
sql: `SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.type_id = '${typeId}' AND ec.deleted_at IS NULL LIMIT 10`,
|
|
31
|
-
key: `find_${name.toLowerCase()}_sample`,
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (components.length >= 2) {
|
|
38
|
-
const typeIds = components
|
|
39
|
-
.slice(0, 3)
|
|
40
|
-
.map((component: { name: string; ctor: any }) =>
|
|
41
|
-
ComponentRegistry.getComponentId(component.name)
|
|
42
|
-
)
|
|
43
|
-
.filter((id: string | undefined) => id)
|
|
44
|
-
.join("','");
|
|
45
|
-
|
|
46
|
-
if (typeIds) {
|
|
47
|
-
commonQueries.push({
|
|
48
|
-
sql: `SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.type_id IN ('${typeIds}') AND ec.deleted_at IS NULL LIMIT 10`,
|
|
49
|
-
key: "find_multi_component_sample",
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
await preparedStatementCache.warmUp(commonQueries, db);
|
|
14
|
+
logger.trace("Prepared statement warm-up skipped (driver-level auto-prepare in effect)");
|
|
55
15
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as path from "path";
|
|
2
2
|
import { logger as MainLogger } from "../Logger";
|
|
3
|
-
import {
|
|
3
|
+
import { getMetadataScript } from "../metadata";
|
|
4
4
|
import { addCorsHeaders, getCorsHeaders } from "./cors";
|
|
5
5
|
import {
|
|
6
6
|
handleHealth,
|
|
@@ -24,6 +24,8 @@ function combineSignals(signals: AbortSignal[]): AbortSignal {
|
|
|
24
24
|
controller.abort((s as any).reason);
|
|
25
25
|
return controller.signal;
|
|
26
26
|
}
|
|
27
|
+
// { once: true } auto-removes the listener after first fire, so no
|
|
28
|
+
// explicit removeEventListener is needed; GC cleans up the rest.
|
|
27
29
|
s.addEventListener('abort', () => controller.abort((s as any).reason), { once: true });
|
|
28
30
|
}
|
|
29
31
|
return controller.signal;
|
|
@@ -58,6 +60,8 @@ export async function handleRequest(app: any, req: Request): Promise<Response> {
|
|
|
58
60
|
msg: 'Request timeout',
|
|
59
61
|
}, `Request timeout: ${method} ${url.pathname}`);
|
|
60
62
|
}, 30000);
|
|
63
|
+
// Prevent the timer from keeping the Bun event loop alive at high concurrency.
|
|
64
|
+
(timeoutId as any).unref?.();
|
|
61
65
|
const combinedSignal = combineSignals([req.signal, controller.signal]);
|
|
62
66
|
req = new Request(req, { signal: combinedSignal });
|
|
63
67
|
|
|
@@ -172,9 +176,7 @@ export async function handleRequest(app: any, req: Request): Promise<Response> {
|
|
|
172
176
|
const studioFile = Bun.file(studioIndexPath);
|
|
173
177
|
if (await studioFile.exists()) {
|
|
174
178
|
let html = await studioFile.text();
|
|
175
|
-
|
|
176
|
-
const metadataScript = `<script>window.bunsaneMetadata = ${JSON.stringify(metadata)};</script>`;
|
|
177
|
-
html = html.replace("</head>", `${metadataScript}</head>`);
|
|
179
|
+
html = html.replace("</head>", `${getMetadataScript()}</head>`);
|
|
178
180
|
return wrap(new Response(html, {
|
|
179
181
|
headers: { "Content-Type": "text/html" },
|
|
180
182
|
}));
|
|
@@ -214,11 +216,10 @@ export async function handleRequest(app: any, req: Request): Promise<Response> {
|
|
|
214
216
|
let endpoint = app.restEndpointMap.get(endpointKey);
|
|
215
217
|
|
|
216
218
|
if (!endpoint) {
|
|
219
|
+
// Only iterate endpoints that have params (regex precompiled at registration).
|
|
217
220
|
for (const ep of app.restEndpoints) {
|
|
218
|
-
if (ep.method !== method) continue;
|
|
219
|
-
|
|
220
|
-
const regex = new RegExp(`^${pattern}$`);
|
|
221
|
-
if (regex.test(url.pathname)) {
|
|
221
|
+
if (!ep.regex || ep.method !== method) continue;
|
|
222
|
+
if (ep.regex.test(url.pathname)) {
|
|
222
223
|
endpoint = ep;
|
|
223
224
|
break;
|
|
224
225
|
}
|
package/core/app/restRegistry.ts
CHANGED
|
@@ -8,9 +8,17 @@ export function collectRestEndpoints(app: any, services: any[]): void {
|
|
|
8
8
|
if (!endpoints) continue;
|
|
9
9
|
|
|
10
10
|
for (const endpoint of endpoints) {
|
|
11
|
+
// Precompile the parameterized regex once so the hot-path router
|
|
12
|
+
// never calls replace + new RegExp per request.
|
|
13
|
+
const hasParams = endpoint.path.includes(':');
|
|
14
|
+
const regex = hasParams
|
|
15
|
+
? new RegExp(`^${endpoint.path.replace(/:[^/]+/g, '[^/]+')}$`)
|
|
16
|
+
: undefined;
|
|
17
|
+
|
|
11
18
|
const endpointInfo = {
|
|
12
19
|
method: endpoint.method,
|
|
13
20
|
path: endpoint.path,
|
|
21
|
+
regex,
|
|
14
22
|
handler: endpoint.handler.bind(service),
|
|
15
23
|
service: service,
|
|
16
24
|
};
|
|
@@ -57,13 +57,22 @@ export function buildFieldResolvers(archetype: any): FieldResolverEntry[] {
|
|
|
57
57
|
|
|
58
58
|
const isUnwrapped = shouldUnwrapComponent(componentProps, fieldType);
|
|
59
59
|
|
|
60
|
+
// Detect whether the unwrapped 'value' prop is a Date so we can
|
|
61
|
+
// normalize Date instances to ISO strings before they reach
|
|
62
|
+
// gqloom's GraphQLString coercion (which would call .valueOf() and
|
|
63
|
+
// emit epoch ms instead).
|
|
64
|
+
const unwrappedValueProp = componentProps.find(p => p.propertyKey === 'value');
|
|
65
|
+
const isUnwrappedDate = isUnwrapped && unwrappedValueProp?.propertyType === Date;
|
|
66
|
+
const normalizeDateValue = (v: any) =>
|
|
67
|
+
isUnwrappedDate && v instanceof Date ? v.toISOString() : v;
|
|
68
|
+
|
|
60
69
|
if (isUnwrapped) {
|
|
61
70
|
resolvers.push({
|
|
62
71
|
typeName: archetypeName,
|
|
63
72
|
fieldName: field,
|
|
64
73
|
resolver: async (parent: any, args: any, context: any) => {
|
|
65
74
|
const entityId = parent?.id;
|
|
66
|
-
if (!entityId) return (parent as any)[field];
|
|
75
|
+
if (!entityId) return normalizeDateValue((parent as any)[field]);
|
|
67
76
|
|
|
68
77
|
if (parent instanceof Entity) {
|
|
69
78
|
if (parent.wasRemoved(componentCtor)) {
|
|
@@ -71,7 +80,7 @@ export function buildFieldResolvers(archetype: any): FieldResolverEntry[] {
|
|
|
71
80
|
}
|
|
72
81
|
const inMemoryComp = parent.getInMemory(componentCtor);
|
|
73
82
|
if (inMemoryComp) {
|
|
74
|
-
return (inMemoryComp as any)?.value;
|
|
83
|
+
return normalizeDateValue((inMemoryComp as any)?.value);
|
|
75
84
|
}
|
|
76
85
|
}
|
|
77
86
|
|
|
@@ -82,13 +91,13 @@ export function buildFieldResolvers(archetype: any): FieldResolverEntry[] {
|
|
|
82
91
|
typeId: typeIdHex,
|
|
83
92
|
});
|
|
84
93
|
if (componentData?.data?.value !== undefined) {
|
|
85
|
-
return componentData.data.value;
|
|
94
|
+
return normalizeDateValue(componentData.data.value);
|
|
86
95
|
}
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
const entity = await ensureEntity(parent, context);
|
|
90
99
|
const comp = await entity.get(componentCtor);
|
|
91
|
-
return (comp as any)?.value;
|
|
100
|
+
return normalizeDateValue((comp as any)?.value);
|
|
92
101
|
},
|
|
93
102
|
});
|
|
94
103
|
} else {
|
|
@@ -129,10 +138,17 @@ export function buildFieldResolvers(archetype: any): FieldResolverEntry[] {
|
|
|
129
138
|
const componentTypeName = compNameToFieldName(componentName);
|
|
130
139
|
|
|
131
140
|
for (const prop of componentProps) {
|
|
141
|
+
const isDateProp = prop.propertyType === Date;
|
|
132
142
|
resolvers.push({
|
|
133
143
|
typeName: componentTypeName,
|
|
134
144
|
fieldName: prop.propertyKey,
|
|
135
|
-
resolver: (parent: any) =>
|
|
145
|
+
resolver: (parent: any) => {
|
|
146
|
+
const v = parent[prop.propertyKey];
|
|
147
|
+
if (isDateProp && v instanceof Date) {
|
|
148
|
+
return v.toISOString();
|
|
149
|
+
}
|
|
150
|
+
return v;
|
|
151
|
+
},
|
|
136
152
|
});
|
|
137
153
|
}
|
|
138
154
|
}
|
|
@@ -313,6 +329,54 @@ export function buildFieldResolvers(archetype: any): FieldResolverEntry[] {
|
|
|
313
329
|
},
|
|
314
330
|
});
|
|
315
331
|
} else if (isArray) {
|
|
332
|
+
// Resolve the FK-bearing component + field ONCE (lazily, then
|
|
333
|
+
// memoized) rather than re-instantiating the related archetype and
|
|
334
|
+
// walking its component metadata on every parent row. The result is
|
|
335
|
+
// captured in the resolver closure.
|
|
336
|
+
let fkResolution:
|
|
337
|
+
| { componentCtor: any; componentTypeId: string; foreignKeyField: string }
|
|
338
|
+
| null
|
|
339
|
+
| undefined;
|
|
340
|
+
const resolveFk = () => {
|
|
341
|
+
if (fkResolution !== undefined) return fkResolution;
|
|
342
|
+
fkResolution = null;
|
|
343
|
+
if (!relationOptions?.foreignKey) return fkResolution;
|
|
344
|
+
|
|
345
|
+
let relatedArchetypeInstance: any = null;
|
|
346
|
+
if (typeof relatedArcheType === "function") {
|
|
347
|
+
relatedArchetypeInstance = new (relatedArcheType as any)();
|
|
348
|
+
} else if (typeof relatedArcheType === "string") {
|
|
349
|
+
const meta = storage.archetypes.find((a) => a.name === relatedArcheType);
|
|
350
|
+
if (meta) relatedArchetypeInstance = new (meta.target as any)();
|
|
351
|
+
}
|
|
352
|
+
if (!relatedArchetypeInstance) return fkResolution;
|
|
353
|
+
|
|
354
|
+
let componentCtor: any = null;
|
|
355
|
+
let foreignKeyField: string = relationOptions.foreignKey;
|
|
356
|
+
if (relationOptions.foreignKey.includes('.')) {
|
|
357
|
+
const [fieldName, propName] = relationOptions.foreignKey.split('.');
|
|
358
|
+
componentCtor = relatedArchetypeInstance.componentMap[fieldName!];
|
|
359
|
+
foreignKeyField = propName!;
|
|
360
|
+
} else {
|
|
361
|
+
for (const comp of Object.values(relatedArchetypeInstance.componentMap) as any[]) {
|
|
362
|
+
const typeId = storage.getComponentId(comp.name);
|
|
363
|
+
const props = storage.getComponentProperties(typeId);
|
|
364
|
+
if (props.some(p => p.propertyKey === relationOptions.foreignKey)) {
|
|
365
|
+
componentCtor = comp;
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (componentCtor) {
|
|
371
|
+
fkResolution = {
|
|
372
|
+
componentCtor,
|
|
373
|
+
componentTypeId: storage.getComponentId(componentCtor.name),
|
|
374
|
+
foreignKeyField,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return fkResolution;
|
|
378
|
+
};
|
|
379
|
+
|
|
316
380
|
resolvers.push({
|
|
317
381
|
typeName: archetypeName,
|
|
318
382
|
fieldName: field,
|
|
@@ -321,44 +385,25 @@ export function buildFieldResolvers(archetype: any): FieldResolverEntry[] {
|
|
|
321
385
|
if (!entityId) return [];
|
|
322
386
|
|
|
323
387
|
if (relationOptions?.foreignKey) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
let relatedArchetypeInstance: any = null;
|
|
327
|
-
|
|
328
|
-
if (typeof relatedArcheType === "function") {
|
|
329
|
-
relatedArchetypeInstance = new (relatedArcheType as any)();
|
|
330
|
-
} else if (typeof relatedArcheType === "string") {
|
|
331
|
-
const relatedArchetypeMetadata = storage.archetypes.find((a) => a.name === relatedArcheType);
|
|
332
|
-
if (relatedArchetypeMetadata) {
|
|
333
|
-
relatedArchetypeInstance = new (relatedArchetypeMetadata.target as any)();
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
if (relatedArchetypeInstance) {
|
|
338
|
-
if (relationOptions.foreignKey.includes('.')) {
|
|
339
|
-
const [fieldName, propName] = relationOptions.foreignKey.split('.');
|
|
340
|
-
componentCtor = relatedArchetypeInstance.componentMap[fieldName!];
|
|
341
|
-
foreignKeyField = propName!;
|
|
342
|
-
} else {
|
|
343
|
-
for (const comp of Object.values(relatedArchetypeInstance.componentMap) as any[]) {
|
|
344
|
-
const typeId = storage.getComponentId(comp.name);
|
|
345
|
-
const props = storage.getComponentProperties(typeId);
|
|
346
|
-
if (props.some(p => p.propertyKey === relationOptions.foreignKey)) {
|
|
347
|
-
componentCtor = comp;
|
|
348
|
-
break;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
if (componentCtor) {
|
|
355
|
-
const query = new Query();
|
|
356
|
-
query.with(componentCtor, Query.filters(Query.filter(foreignKeyField, Query.filterOp.EQ, entityId)));
|
|
357
|
-
return await query.exec();
|
|
358
|
-
} else {
|
|
388
|
+
const r = resolveFk();
|
|
389
|
+
if (!r) {
|
|
359
390
|
console.warn(`No component found with foreign key ${relationOptions.foreignKey} in ${relatedTypeName}`);
|
|
360
391
|
return [];
|
|
361
392
|
}
|
|
393
|
+
// Batched path: dedups across sibling parents in the
|
|
394
|
+
// same request via the type-scoped FK loader (was N+1).
|
|
395
|
+
if (context?.loaders?.relationsByComponentFk) {
|
|
396
|
+
return await context.loaders.relationsByComponentFk.load({
|
|
397
|
+
entityId,
|
|
398
|
+
componentTypeId: r.componentTypeId,
|
|
399
|
+
foreignKeyField: r.foreignKeyField,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
// Fallback for non-request contexts (direct service
|
|
403
|
+
// calls with no loaders mounted): single query.
|
|
404
|
+
const query = new Query();
|
|
405
|
+
query.with(r.componentCtor, Query.filters(Query.filter(r.foreignKeyField, Query.filterOp.EQ, entityId)));
|
|
406
|
+
return await query.exec();
|
|
362
407
|
} else {
|
|
363
408
|
if (context?.loaders?.relationsByEntityField) {
|
|
364
409
|
return context.loaders.relationsByEntityField.load({
|
|
@@ -1,118 +1,161 @@
|
|
|
1
1
|
import { Entity } from "../Entity";
|
|
2
2
|
import { getMetadataStorage } from "../metadata";
|
|
3
3
|
import { Query } from "../../query";
|
|
4
|
+
import { getRequestScope } from "../requestScope";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Populate relation fields on an entity according to the archetype's relationMap.
|
|
7
8
|
* Extracted from BaseArcheType.populateRelations().
|
|
9
|
+
*
|
|
10
|
+
* When called inside a request scope (GraphQL execution), relation loads go
|
|
11
|
+
* through the request's DataLoaders so sibling entities resolved in the same
|
|
12
|
+
* tick batch into single queries (previously: one `new Query()` per relation
|
|
13
|
+
* per entity — a hard N+1). Relation fields of one entity are resolved
|
|
14
|
+
* concurrently for the same reason.
|
|
8
15
|
*/
|
|
9
16
|
export async function populateRelations(archetype: any, entity: Entity): Promise<void> {
|
|
10
17
|
const storage = getMetadataStorage();
|
|
11
18
|
|
|
19
|
+
const fieldPromises: Promise<void>[] = [];
|
|
12
20
|
for (const [fieldName, relatedArchetype] of Object.entries(archetype.relationMap)) {
|
|
13
21
|
const relationType = archetype.relationTypes[fieldName];
|
|
14
22
|
const relationOptions = archetype.relationOptions[fieldName];
|
|
15
23
|
|
|
16
24
|
if (relationType === "belongsTo") {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (compCtor) {
|
|
25
|
-
const componentInstance = await entity.get(compCtor as any);
|
|
26
|
-
if (componentInstance && (componentInstance as any)[propName!] !== undefined) {
|
|
27
|
-
foreignId = (componentInstance as any)[propName!];
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
} else {
|
|
31
|
-
const candidateComponents: Array<{ compCtor: any }> = [];
|
|
32
|
-
for (const compCtor of Object.values(archetype.componentMap)) {
|
|
33
|
-
const compCtorAny = compCtor as any;
|
|
34
|
-
const typeId = storage.getComponentId(compCtorAny.name);
|
|
35
|
-
const componentProps = storage.getComponentProperties(typeId);
|
|
36
|
-
const hasForeignKey = componentProps.some(prop => prop.propertyKey === foreignKey);
|
|
37
|
-
if (hasForeignKey) {
|
|
38
|
-
candidateComponents.push({ compCtor: compCtorAny });
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (candidateComponents.length > 0) {
|
|
43
|
-
const componentInstances = await Promise.all(
|
|
44
|
-
candidateComponents.map(({ compCtor }) => entity.get(compCtor as any))
|
|
45
|
-
);
|
|
46
|
-
|
|
47
|
-
for (const componentInstance of componentInstances) {
|
|
48
|
-
if (componentInstance && (componentInstance as any)[foreignKey] !== undefined) {
|
|
49
|
-
foreignId = (componentInstance as any)[foreignKey];
|
|
50
|
-
break;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
25
|
+
fieldPromises.push(populateBelongsTo(archetype, entity, fieldName, relatedArchetype, relationOptions, storage));
|
|
26
|
+
} else if (relationType === "hasMany") {
|
|
27
|
+
fieldPromises.push(populateHasMany(entity, fieldName, relatedArchetype, relationOptions, storage));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
await Promise.all(fieldPromises);
|
|
31
|
+
}
|
|
55
32
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
33
|
+
function resolveRelatedArchetypeInstance(relatedArchetype: any, storage: any): any | null {
|
|
34
|
+
if (typeof relatedArchetype === "function") {
|
|
35
|
+
return new (relatedArchetype as any)();
|
|
36
|
+
}
|
|
37
|
+
const meta = storage.archetypes.find((a: any) => a.name === relatedArchetype);
|
|
38
|
+
return meta ? new (meta.target as any)() : null;
|
|
39
|
+
}
|
|
59
40
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const relatedEntity = await relatedArchetypeInstance.getEntityWithID(foreignId);
|
|
74
|
-
if (relatedEntity) {
|
|
75
|
-
(entity as any)[fieldName] = relatedEntity;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
} else if (relationType === "hasMany") {
|
|
80
|
-
const foreignKey = relationOptions?.foreignKey;
|
|
81
|
-
if (foreignKey) {
|
|
82
|
-
let relatedArchetypeInstance: any;
|
|
83
|
-
if (typeof relatedArchetype === "function") {
|
|
84
|
-
relatedArchetypeInstance = new (relatedArchetype as any)();
|
|
85
|
-
} else {
|
|
86
|
-
const relatedArchetypeMetadata = storage.archetypes.find((a) => a.name === relatedArchetype);
|
|
87
|
-
if (relatedArchetypeMetadata) {
|
|
88
|
-
relatedArchetypeInstance = new (relatedArchetypeMetadata.target as any)();
|
|
89
|
-
} else {
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
41
|
+
async function populateBelongsTo(
|
|
42
|
+
archetype: any,
|
|
43
|
+
entity: Entity,
|
|
44
|
+
fieldName: string,
|
|
45
|
+
relatedArchetype: any,
|
|
46
|
+
relationOptions: any,
|
|
47
|
+
storage: any,
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
const foreignKey = relationOptions?.foreignKey;
|
|
50
|
+
if (!foreignKey) return;
|
|
93
51
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
52
|
+
let foreignId: string | undefined;
|
|
53
|
+
|
|
54
|
+
if (foreignKey.includes('.')) {
|
|
55
|
+
const [innerField, propName] = foreignKey.split('.');
|
|
56
|
+
const compCtor = archetype.componentMap[innerField!];
|
|
57
|
+
if (compCtor) {
|
|
58
|
+
// entity.get batches via the ambient request scope when present
|
|
59
|
+
const componentInstance = await entity.get(compCtor as any);
|
|
60
|
+
if (componentInstance && (componentInstance as any)[propName!] !== undefined) {
|
|
61
|
+
foreignId = (componentInstance as any)[propName!];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
const candidateComponents: Array<{ compCtor: any }> = [];
|
|
66
|
+
for (const compCtor of Object.values(archetype.componentMap)) {
|
|
67
|
+
const compCtorAny = compCtor as any;
|
|
68
|
+
const typeId = storage.getComponentId(compCtorAny.name);
|
|
69
|
+
const componentProps = storage.getComponentProperties(typeId);
|
|
70
|
+
const hasForeignKey = componentProps.some((prop: any) => prop.propertyKey === foreignKey);
|
|
71
|
+
if (hasForeignKey) {
|
|
72
|
+
candidateComponents.push({ compCtor: compCtorAny });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
105
75
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
})
|
|
111
|
-
.exec();
|
|
76
|
+
if (candidateComponents.length > 0) {
|
|
77
|
+
const componentInstances = await Promise.all(
|
|
78
|
+
candidateComponents.map(({ compCtor }) => entity.get(compCtor as any))
|
|
79
|
+
);
|
|
112
80
|
|
|
113
|
-
|
|
81
|
+
for (const componentInstance of componentInstances) {
|
|
82
|
+
if (componentInstance && (componentInstance as any)[foreignKey] !== undefined) {
|
|
83
|
+
foreignId = (componentInstance as any)[foreignKey];
|
|
84
|
+
break;
|
|
114
85
|
}
|
|
115
86
|
}
|
|
116
87
|
}
|
|
117
88
|
}
|
|
89
|
+
|
|
90
|
+
if (!foreignId && foreignKey === 'id') {
|
|
91
|
+
foreignId = entity.id;
|
|
92
|
+
}
|
|
93
|
+
if (!foreignId) return;
|
|
94
|
+
|
|
95
|
+
// Batched path: the request-scoped entityById loader dedups/batches
|
|
96
|
+
// sibling lookups. The returned shell entity lazy-loads components
|
|
97
|
+
// through the same scope's component loader.
|
|
98
|
+
const scope = getRequestScope();
|
|
99
|
+
if (scope?.loaders?.entityById) {
|
|
100
|
+
const relatedEntity = await scope.loaders.entityById.load(foreignId);
|
|
101
|
+
if (relatedEntity) {
|
|
102
|
+
(entity as any)[fieldName] = relatedEntity;
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const relatedArchetypeInstance = resolveRelatedArchetypeInstance(relatedArchetype, storage);
|
|
108
|
+
if (!relatedArchetypeInstance) return;
|
|
109
|
+
const relatedEntity = await relatedArchetypeInstance.getEntityWithID(foreignId);
|
|
110
|
+
if (relatedEntity) {
|
|
111
|
+
(entity as any)[fieldName] = relatedEntity;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function populateHasMany(
|
|
116
|
+
entity: Entity,
|
|
117
|
+
fieldName: string,
|
|
118
|
+
relatedArchetype: any,
|
|
119
|
+
relationOptions: any,
|
|
120
|
+
storage: any,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
const foreignKey = relationOptions?.foreignKey;
|
|
123
|
+
if (!foreignKey) return;
|
|
124
|
+
|
|
125
|
+
const relatedArchetypeInstance = resolveRelatedArchetypeInstance(relatedArchetype, storage);
|
|
126
|
+
if (!relatedArchetypeInstance) return;
|
|
127
|
+
|
|
128
|
+
let foreignKeyComponent: any = null;
|
|
129
|
+
for (const compCtor of Object.values(relatedArchetypeInstance.componentMap)) {
|
|
130
|
+
const compCtorAny = compCtor as any;
|
|
131
|
+
const typeId = storage.getComponentId(compCtorAny.name);
|
|
132
|
+
const componentProps = storage.getComponentProperties(typeId);
|
|
133
|
+
const hasForeignKey = componentProps.some((prop: any) => prop.propertyKey === foreignKey);
|
|
134
|
+
if (hasForeignKey) {
|
|
135
|
+
foreignKeyComponent = compCtorAny;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!foreignKeyComponent) return;
|
|
140
|
+
|
|
141
|
+
// Batched path: type-scoped FK loader collapses sibling parents sharing
|
|
142
|
+
// the same (componentType, fkField) into one query.
|
|
143
|
+
const scope = getRequestScope();
|
|
144
|
+
if (scope?.loaders?.relationsByComponentFk) {
|
|
145
|
+
const componentTypeId = storage.getComponentId(foreignKeyComponent.name);
|
|
146
|
+
(entity as any)[fieldName] = await scope.loaders.relationsByComponentFk.load({
|
|
147
|
+
entityId: entity.id,
|
|
148
|
+
componentTypeId,
|
|
149
|
+
foreignKeyField: foreignKey,
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const matchingEntities = await new Query()
|
|
155
|
+
.with(foreignKeyComponent, {
|
|
156
|
+
filters: [{ field: foreignKey, operator: '=', value: entity.id }]
|
|
157
|
+
})
|
|
158
|
+
.exec();
|
|
159
|
+
|
|
160
|
+
(entity as any)[fieldName] = matchingEntities;
|
|
118
161
|
}
|