bunsane 0.3.1 → 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 -318
- package/config/cache.config.ts +35 -1
- package/core/App.ts +24 -1064
- package/core/ArcheType.ts +78 -2110
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +85 -1043
- package/core/EntityHookManager.ts +52 -754
- package/core/Logger.ts +10 -0
- package/core/RequestContext.ts +64 -6
- package/core/RequestLoaders.ts +187 -36
- package/core/SchedulerManager.ts +28 -600
- package/core/app/bootstrap.ts +133 -0
- package/core/app/cors.ts +85 -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 +15 -0
- package/core/app/processHandlers.ts +43 -0
- package/core/app/requestRouter.ts +310 -0
- package/core/app/restRegistry.ts +80 -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 +666 -0
- package/core/archetype/helpers.ts +29 -0
- package/core/archetype/relationLoader.ts +161 -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 +173 -267
- 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/AccessLog.ts +8 -1
- 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 +20 -5
- package/database/cancellable.ts +35 -0
- package/database/index.ts +15 -3
- package/database/instrumentedDb.ts +141 -0
- 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 +203 -59
- 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/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/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/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 -367
- package/tests/unit/cache/MemoryCache.test.ts +0 -260
- package/tests/unit/cache/RedisCache.test.ts +0 -411
- 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
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ComponentPropertyMetadata } from "../metadata/definitions/Component";
|
|
2
|
+
|
|
3
|
+
export const primitiveTypes = [String, Number, Boolean, Date];
|
|
4
|
+
|
|
5
|
+
export function compNameToFieldName(compName: string): string {
|
|
6
|
+
return (
|
|
7
|
+
compName.charAt(0).toLowerCase() +
|
|
8
|
+
compName.slice(1).replace(/Component$/, "Component")
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Helper to determine if a component should be unwrapped to a scalar value.
|
|
14
|
+
* Returns true if the component has a single 'value' property and the field type is primitive.
|
|
15
|
+
*/
|
|
16
|
+
export function shouldUnwrapComponent(
|
|
17
|
+
componentProps: ComponentPropertyMetadata[],
|
|
18
|
+
fieldType: any
|
|
19
|
+
): boolean {
|
|
20
|
+
if (
|
|
21
|
+
fieldType === String ||
|
|
22
|
+
fieldType === Number ||
|
|
23
|
+
fieldType === Boolean ||
|
|
24
|
+
fieldType === Date
|
|
25
|
+
) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { Entity } from "../Entity";
|
|
2
|
+
import { getMetadataStorage } from "../metadata";
|
|
3
|
+
import { Query } from "../../query";
|
|
4
|
+
import { getRequestScope } from "../requestScope";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Populate relation fields on an entity according to the archetype's relationMap.
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
export async function populateRelations(archetype: any, entity: Entity): Promise<void> {
|
|
17
|
+
const storage = getMetadataStorage();
|
|
18
|
+
|
|
19
|
+
const fieldPromises: Promise<void>[] = [];
|
|
20
|
+
for (const [fieldName, relatedArchetype] of Object.entries(archetype.relationMap)) {
|
|
21
|
+
const relationType = archetype.relationTypes[fieldName];
|
|
22
|
+
const relationOptions = archetype.relationOptions[fieldName];
|
|
23
|
+
|
|
24
|
+
if (relationType === "belongsTo") {
|
|
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
|
+
}
|
|
32
|
+
|
|
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
|
+
}
|
|
40
|
+
|
|
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;
|
|
51
|
+
|
|
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
|
+
}
|
|
75
|
+
|
|
76
|
+
if (candidateComponents.length > 0) {
|
|
77
|
+
const componentInstances = await Promise.all(
|
|
78
|
+
candidateComponents.map(({ compCtor }) => entity.get(compCtor as any))
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
for (const componentInstance of componentInstances) {
|
|
82
|
+
if (componentInstance && (componentInstance as any)[foreignKey] !== undefined) {
|
|
83
|
+
foreignId = (componentInstance as any)[foreignKey];
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
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;
|
|
161
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { BaseComponent } from "../components";
|
|
2
|
+
import type { ArcheTypeFieldOptions } from "../metadata/definitions/ArcheType";
|
|
3
|
+
import { z, ZodObject } from "zod";
|
|
4
|
+
import { asEnumType } from "@gqloom/zod";
|
|
5
|
+
import { getMetadataStorage } from "../metadata";
|
|
6
|
+
import { compNameToFieldName, primitiveTypes } from "./helpers";
|
|
7
|
+
import { customTypeRegistry } from "./customTypes";
|
|
8
|
+
|
|
9
|
+
// Component-level schema cache
|
|
10
|
+
export const componentSchemaCache = new Map<string, ZodObject<any>>();
|
|
11
|
+
|
|
12
|
+
// Enum schema cache to prevent duplicate registrations
|
|
13
|
+
export const enumSchemaCache = new Map<string, any>();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generate Zod schema for a component and cache it.
|
|
17
|
+
*/
|
|
18
|
+
export function getOrCreateComponentSchema(
|
|
19
|
+
componentCtor: new (...args: any[]) => BaseComponent,
|
|
20
|
+
componentId: string,
|
|
21
|
+
fieldOptions?: ArcheTypeFieldOptions
|
|
22
|
+
): any | null {
|
|
23
|
+
if (componentSchemaCache.has(componentId)) {
|
|
24
|
+
return componentSchemaCache.get(componentId)!;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const storage = getMetadataStorage();
|
|
28
|
+
const props = storage.getComponentProperties(componentId);
|
|
29
|
+
|
|
30
|
+
if (props.length === 0) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const zodFields: Record<string, any> = {
|
|
35
|
+
__typename: z
|
|
36
|
+
.literal(compNameToFieldName(componentCtor.name))
|
|
37
|
+
.nullish(),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
for (const prop of props) {
|
|
41
|
+
if (prop.isPrimitive) {
|
|
42
|
+
switch (prop.propertyType) {
|
|
43
|
+
case String:
|
|
44
|
+
zodFields[prop.propertyKey] = z.string();
|
|
45
|
+
break;
|
|
46
|
+
case Number:
|
|
47
|
+
zodFields[prop.propertyKey] = z.number();
|
|
48
|
+
break;
|
|
49
|
+
case Boolean:
|
|
50
|
+
zodFields[prop.propertyKey] = z.boolean();
|
|
51
|
+
break;
|
|
52
|
+
case Date:
|
|
53
|
+
zodFields[prop.propertyKey] = z.date();
|
|
54
|
+
break;
|
|
55
|
+
default:
|
|
56
|
+
console.warn(`[ArcheType] Unknown primitive type for ${componentCtor.name}.${prop.propertyKey}: ${prop.propertyType?.name}. Falling back to z.string()`);
|
|
57
|
+
zodFields[prop.propertyKey] = z.string();
|
|
58
|
+
}
|
|
59
|
+
if (prop.isOptional) {
|
|
60
|
+
zodFields[prop.propertyKey] =
|
|
61
|
+
zodFields[prop.propertyKey].optional();
|
|
62
|
+
}
|
|
63
|
+
} else if (prop.isEnum && prop.enumValues && prop.enumKeys) {
|
|
64
|
+
const enumTypeName =
|
|
65
|
+
prop.propertyType?.name ||
|
|
66
|
+
`${componentCtor.name}_${prop.propertyKey}_Enum`;
|
|
67
|
+
|
|
68
|
+
let enumSchema = enumSchemaCache.get(enumTypeName);
|
|
69
|
+
|
|
70
|
+
if (!enumSchema) {
|
|
71
|
+
enumSchema = z
|
|
72
|
+
.enum(prop.enumValues as any)
|
|
73
|
+
.register(asEnumType, {
|
|
74
|
+
name: enumTypeName,
|
|
75
|
+
valuesConfig: prop.enumKeys.reduce(
|
|
76
|
+
(
|
|
77
|
+
acc: Record<string, { description: string }>,
|
|
78
|
+
key,
|
|
79
|
+
idx
|
|
80
|
+
) => {
|
|
81
|
+
acc[key] = { description: prop.enumValues![idx]! };
|
|
82
|
+
return acc;
|
|
83
|
+
},
|
|
84
|
+
{}
|
|
85
|
+
),
|
|
86
|
+
});
|
|
87
|
+
enumSchemaCache.set(enumTypeName, enumSchema);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
zodFields[prop.propertyKey] = enumSchema;
|
|
91
|
+
if (prop.isOptional) {
|
|
92
|
+
zodFields[prop.propertyKey] =
|
|
93
|
+
zodFields[prop.propertyKey].optional();
|
|
94
|
+
}
|
|
95
|
+
} else if (customTypeRegistry.has(prop.propertyType)) {
|
|
96
|
+
zodFields[prop.propertyKey] = customTypeRegistry.get(
|
|
97
|
+
prop.propertyType
|
|
98
|
+
)!;
|
|
99
|
+
if (prop.isOptional) {
|
|
100
|
+
zodFields[prop.propertyKey] =
|
|
101
|
+
zodFields[prop.propertyKey].optional();
|
|
102
|
+
}
|
|
103
|
+
} else if (prop.arrayOf) {
|
|
104
|
+
if (customTypeRegistry.has(prop.arrayOf)) {
|
|
105
|
+
zodFields[prop.propertyKey] = z.array(customTypeRegistry.get(prop.arrayOf)!);
|
|
106
|
+
} else if (primitiveTypes.includes(prop.arrayOf)) {
|
|
107
|
+
if (prop.arrayOf === String) {
|
|
108
|
+
zodFields[prop.propertyKey] = z.array(z.string());
|
|
109
|
+
} else if (prop.arrayOf === Number) {
|
|
110
|
+
zodFields[prop.propertyKey] = z.array(z.number());
|
|
111
|
+
} else if (prop.arrayOf === Boolean) {
|
|
112
|
+
zodFields[prop.propertyKey] = z.array(z.boolean());
|
|
113
|
+
} else if (prop.arrayOf === Date) {
|
|
114
|
+
zodFields[prop.propertyKey] = z.array(z.date());
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
console.warn(`[ArcheType] Unknown array element type for ${componentCtor.name}.${prop.propertyKey}: ${prop.arrayOf?.name}. Falling back to z.array(z.string())`);
|
|
118
|
+
zodFields[prop.propertyKey] = z.array(z.string());
|
|
119
|
+
}
|
|
120
|
+
if (prop.isOptional) {
|
|
121
|
+
zodFields[prop.propertyKey] = zodFields[prop.propertyKey].optional();
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
console.warn(`[ArcheType] Unknown type for ${componentCtor.name}.${prop.propertyKey}: ${prop.propertyType?.name}. Falling back to z.string()`);
|
|
125
|
+
zodFields[prop.propertyKey] = z.string();
|
|
126
|
+
if (prop.isOptional) {
|
|
127
|
+
zodFields[prop.propertyKey] =
|
|
128
|
+
zodFields[prop.propertyKey].optional();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (fieldOptions?.nullable) {
|
|
133
|
+
zodFields[prop.propertyKey] = zodFields[prop.propertyKey].nullish();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const componentSchema = z.object(zodFields);
|
|
138
|
+
componentSchemaCache.set(componentId, componentSchema);
|
|
139
|
+
|
|
140
|
+
return componentSchema;
|
|
141
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { ZodObject } from "zod";
|
|
2
|
+
import { weave } from "@gqloom/core";
|
|
3
|
+
import { ZodWeaver } from "@gqloom/zod";
|
|
4
|
+
import { printSchema } from "graphql";
|
|
5
|
+
import { getMetadataStorage } from "../metadata";
|
|
6
|
+
import { componentSchemaCache } from "./schemaBuilder";
|
|
7
|
+
import { inputTypeRegistry, customTypeNameRegistry } from "./customTypes";
|
|
8
|
+
|
|
9
|
+
export const archetypeSchemaCache = new Map<
|
|
10
|
+
string,
|
|
11
|
+
{ zodSchema: ZodObject<any>; graphqlSchema: string }
|
|
12
|
+
>();
|
|
13
|
+
export const allArchetypeZodObjects = new Map<string, ZodObject<any>>();
|
|
14
|
+
|
|
15
|
+
export function getArchetypeSchema(archetypeName: string, excludeRelations = false, excludeFunctions = false) {
|
|
16
|
+
const cacheKey = `${archetypeName}_${excludeRelations}_${excludeFunctions}`;
|
|
17
|
+
return archetypeSchemaCache.get(cacheKey);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getAllArchetypeSchemas() {
|
|
21
|
+
return Array.from(archetypeSchemaCache.entries())
|
|
22
|
+
.filter(([key]) => key.endsWith('_false_false'))
|
|
23
|
+
.map(([, value]) => value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function weaveAllArchetypes() {
|
|
27
|
+
const storage = getMetadataStorage();
|
|
28
|
+
const archetypeNames: string[] = [];
|
|
29
|
+
|
|
30
|
+
for (const archetypeMetadata of storage.archetypes) {
|
|
31
|
+
const archetypeName = archetypeMetadata.name;
|
|
32
|
+
archetypeNames.push(archetypeName);
|
|
33
|
+
const fullSchemaCacheKey = `${archetypeName}_false_false`;
|
|
34
|
+
if (!archetypeSchemaCache.has(fullSchemaCacheKey)) {
|
|
35
|
+
try {
|
|
36
|
+
const ArchetypeClass = archetypeMetadata.target as any;
|
|
37
|
+
const instance = new ArchetypeClass();
|
|
38
|
+
instance.getZodObjectSchema();
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.warn(
|
|
41
|
+
`Could not generate schema for archetype ${archetypeName}:`,
|
|
42
|
+
error
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (allArchetypeZodObjects.size === 0) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const archetypeSchemas = Array.from(allArchetypeZodObjects.values());
|
|
52
|
+
const componentSchemas = Array.from(componentSchemaCache.values());
|
|
53
|
+
|
|
54
|
+
const allSchemas = archetypeSchemas;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const schema = weave(ZodWeaver, ...allSchemas);
|
|
58
|
+
let schemaString = printSchema(schema);
|
|
59
|
+
|
|
60
|
+
if (!schemaString.includes('scalar Date')) {
|
|
61
|
+
schemaString = 'scalar Date\n\n' + schemaString;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
schemaString = schemaString.replace(/\bid:\s*String\b/g, "id: ID");
|
|
65
|
+
|
|
66
|
+
schemaString = schemaString.replace(/\b(\w*_at|\w*_date|\w*Date|date\w*):\s*String(!?)/gi, (match, fieldName, nullable) => {
|
|
67
|
+
return `${fieldName}: Date${nullable}`;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
for (const archetypeMetadata of storage.archetypes) {
|
|
71
|
+
const archetypeName = archetypeMetadata.name;
|
|
72
|
+
try {
|
|
73
|
+
const ArchetypeClass = archetypeMetadata.target as any;
|
|
74
|
+
const instance = new ArchetypeClass();
|
|
75
|
+
|
|
76
|
+
for (const [field, relatedArcheType] of Object.entries(instance.relationMap)) {
|
|
77
|
+
const relationType = instance.relationTypes[field];
|
|
78
|
+
const isArray = relationType === "hasMany" || relationType === "belongsToMany";
|
|
79
|
+
|
|
80
|
+
let relatedTypeName: string;
|
|
81
|
+
if (typeof relatedArcheType === "string") {
|
|
82
|
+
relatedTypeName = relatedArcheType;
|
|
83
|
+
} else {
|
|
84
|
+
const relatedArchetypeId = storage.getComponentId((relatedArcheType as any).name);
|
|
85
|
+
const relatedArchetypeMetadata = storage.archetypes.find(
|
|
86
|
+
(a) => a.typeId === relatedArchetypeId
|
|
87
|
+
);
|
|
88
|
+
relatedTypeName = relatedArchetypeMetadata?.name || (relatedArcheType as any).name.replace(/ArcheType$/, "");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (isArray) {
|
|
92
|
+
const hasDescription = new RegExp(`"""Reference to ${relatedTypeName} type"""[\\s\\S]{0,50}${field}:`).test(schemaString);
|
|
93
|
+
if (!hasDescription) {
|
|
94
|
+
const addDescPattern = new RegExp(
|
|
95
|
+
`(type ${archetypeName} \\{[\\s\\S]*?)(\\n\\s+)(${field}:\\s*\\[String!?\\]!?)`,
|
|
96
|
+
"g"
|
|
97
|
+
);
|
|
98
|
+
schemaString = schemaString.replace(
|
|
99
|
+
addDescPattern,
|
|
100
|
+
`$1$2"""Reference to ${relatedTypeName} type"""$2$3`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const shouldBeRequired = instance.relationOptions[field]?.nullable === false;
|
|
105
|
+
const suffix = shouldBeRequired ? "!" : "";
|
|
106
|
+
const replacePattern = new RegExp(
|
|
107
|
+
`(type ${archetypeName} \\{[\\s\\S]*?${field}:\\s*)\\[String!?\\](!?)`,
|
|
108
|
+
"g"
|
|
109
|
+
);
|
|
110
|
+
schemaString = schemaString.replace(
|
|
111
|
+
replacePattern,
|
|
112
|
+
`$1[${relatedTypeName}!]${suffix}`
|
|
113
|
+
);
|
|
114
|
+
} else {
|
|
115
|
+
const pattern = new RegExp(
|
|
116
|
+
`(type ${archetypeName} \\{[\\s\\S]*?${field}:\\s*)String(!?)`,
|
|
117
|
+
"g"
|
|
118
|
+
);
|
|
119
|
+
const isNullable = instance.relationOptions[field]?.nullable;
|
|
120
|
+
const suffix = isNullable ? "" : "!";
|
|
121
|
+
schemaString = schemaString.replace(
|
|
122
|
+
pattern,
|
|
123
|
+
`$1${relatedTypeName}${suffix}`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.warn(`Could not process relations for archetype ${archetypeMetadata.name}:`, error);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (archetypeMetadata.functions) {
|
|
132
|
+
for (const { propertyKey, options } of archetypeMetadata.functions) {
|
|
133
|
+
|
|
134
|
+
if (options?.args && options.args.length > 0) {
|
|
135
|
+
const argDefs: string[] = [];
|
|
136
|
+
for (const arg of options.args) {
|
|
137
|
+
let argTypeName: string;
|
|
138
|
+
|
|
139
|
+
const inputTypeName = inputTypeRegistry.get(arg.type);
|
|
140
|
+
if (inputTypeName) {
|
|
141
|
+
argTypeName = inputTypeName;
|
|
142
|
+
} else {
|
|
143
|
+
const registeredTypeName = customTypeNameRegistry.get(arg.type);
|
|
144
|
+
if (registeredTypeName) {
|
|
145
|
+
argTypeName = registeredTypeName;
|
|
146
|
+
} else if (arg.type === String) {
|
|
147
|
+
argTypeName = 'String';
|
|
148
|
+
} else if (arg.type === Number) {
|
|
149
|
+
argTypeName = 'Float';
|
|
150
|
+
} else if (arg.type === Boolean) {
|
|
151
|
+
argTypeName = 'Boolean';
|
|
152
|
+
} else if (arg.type === Date) {
|
|
153
|
+
argTypeName = 'Date';
|
|
154
|
+
} else if (arg.type?.name) {
|
|
155
|
+
argTypeName = arg.type.name;
|
|
156
|
+
} else {
|
|
157
|
+
argTypeName = 'String';
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const nullable = arg.nullable ? '' : '!';
|
|
162
|
+
argDefs.push(`${arg.name}: ${argTypeName}${nullable}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const argsString = argDefs.join(', ');
|
|
166
|
+
const escapedKey = propertyKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
167
|
+
|
|
168
|
+
const argPattern = new RegExp(
|
|
169
|
+
`(\\s+)(${escapedKey}\\??\\s*:\\s*)([^\\n]+)`,
|
|
170
|
+
'g'
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
schemaString = schemaString.replace(
|
|
174
|
+
argPattern,
|
|
175
|
+
(match, leadingSpace, fieldDef, returnType) => {
|
|
176
|
+
return `${leadingSpace}${fieldDef.trim().replace(':', '')}(${argsString}): ${returnType.trim()}`;
|
|
177
|
+
}
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (options?.returnType && !['string', 'number', 'boolean'].includes(options.returnType)) {
|
|
182
|
+
const typePattern = new RegExp(`type ${archetypeName}\\s*\\{([\\s\\S]*?)\\n\\}`, 'g');
|
|
183
|
+
const typeMatch = typePattern.exec(schemaString);
|
|
184
|
+
|
|
185
|
+
if (typeMatch) {
|
|
186
|
+
const typeBody = typeMatch[1]!;
|
|
187
|
+
|
|
188
|
+
const fieldIndex = typeBody.indexOf(` ${propertyKey}`);
|
|
189
|
+
if (fieldIndex !== -1) {
|
|
190
|
+
const lineStart = fieldIndex;
|
|
191
|
+
const lineEnd = typeBody.indexOf('\n', fieldIndex);
|
|
192
|
+
const fieldLine = typeBody.substring(lineStart, lineEnd !== -1 ? lineEnd : typeBody.length);
|
|
193
|
+
|
|
194
|
+
const updatedLine = fieldLine.replace(/:\s*String(\??)(\s*)$/, `: ${options.returnType}$1$2`);
|
|
195
|
+
|
|
196
|
+
if (updatedLine !== fieldLine) {
|
|
197
|
+
const fullFieldIndex = schemaString.indexOf(typeMatch[0]) + typeMatch[0].indexOf(fieldLine);
|
|
198
|
+
schemaString = schemaString.substring(0, fullFieldIndex) +
|
|
199
|
+
updatedLine +
|
|
200
|
+
schemaString.substring(fullFieldIndex + fieldLine.length);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return schemaString;
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.warn(
|
|
212
|
+
`Failed to weave all archetypes due to duplicate types.\n` +
|
|
213
|
+
`Archetypes being processed: ${archetypeNames.join(', ')}\n` +
|
|
214
|
+
`Error: ${error}`
|
|
215
|
+
);
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|