bunsane 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/CHANGELOG.md +104 -0
  3. package/CLAUDE.md +20 -0
  4. package/config/cache.config.ts +35 -1
  5. package/core/App.ts +24 -1060
  6. package/core/ArcheType.ts +78 -2110
  7. package/core/Entity.ts +136 -41
  8. package/core/RequestContext.ts +85 -36
  9. package/core/RequestLoaders.ts +89 -31
  10. package/core/SchedulerManager.ts +13 -13
  11. package/core/app/bootstrap.ts +133 -0
  12. package/core/app/cors.ts +94 -0
  13. package/core/app/graphqlSetup.ts +56 -0
  14. package/core/app/healthEndpoints.ts +31 -0
  15. package/core/app/metricsCollector.ts +27 -0
  16. package/core/app/preparedStatementWarmup.ts +55 -0
  17. package/core/app/processHandlers.ts +43 -0
  18. package/core/app/requestRouter.ts +309 -0
  19. package/core/app/restRegistry.ts +72 -0
  20. package/core/app/shutdown.ts +97 -0
  21. package/core/app/studioRouter.ts +83 -0
  22. package/core/archetype/customTypes.ts +100 -0
  23. package/core/archetype/decorators.ts +171 -0
  24. package/core/archetype/fieldResolvers.ts +621 -0
  25. package/core/archetype/helpers.ts +29 -0
  26. package/core/archetype/relationLoader.ts +118 -0
  27. package/core/archetype/schemaBuilder.ts +141 -0
  28. package/core/archetype/weaver.ts +218 -0
  29. package/core/archetype/zodSchemaBuilder.ts +527 -0
  30. package/core/cache/CacheManager.ts +144 -9
  31. package/core/components/BaseComponent.ts +12 -2
  32. package/core/middleware/AccessLog.ts +8 -1
  33. package/database/PreparedStatementCache.ts +17 -16
  34. package/database/cancellable.ts +22 -0
  35. package/database/instrumentedDb.ts +141 -0
  36. package/docs/RFC_APP_REFACTOR.md +248 -0
  37. package/docs/RFC_REFACTOR_TARGETS.md +251 -0
  38. package/package.json +1 -1
  39. package/query/ComponentInclusionNode.ts +5 -5
  40. package/query/Query.ts +65 -48
  41. package/service/ServiceRegistry.ts +7 -1
  42. package/service/index.ts +4 -2
  43. package/tests/integration/loaders/RequestLoaders.abort.test.ts +82 -0
  44. package/tests/integration/query/Query.abort.test.ts +66 -0
  45. package/tests/unit/cache/CacheManager.test.ts +152 -1
  46. package/tests/unit/database/cancellable.test.ts +81 -0
  47. package/tests/unit/database/instrumentedDb.test.ts +160 -0
  48. package/tests/unit/entity/Entity.components.test.ts +73 -0
  49. package/tests/unit/entity/Entity.drainSideEffects.test.ts +51 -0
  50. package/tests/unit/entity/Entity.reload.test.ts +63 -0
  51. package/tests/unit/entity/Entity.requireComponents.test.ts +72 -0
  52. package/tests/unit/query/Query.emptyString.test.ts +69 -0
  53. package/tests/unit/query/Query.test.ts +6 -4
  54. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +95 -0
@@ -0,0 +1,309 @@
1
+ import * as path from "path";
2
+ import { logger as MainLogger } from "../Logger";
3
+ import { getSerializedMetadataStorage } from "../metadata";
4
+ import { addCorsHeaders, getCorsHeaders } from "./cors";
5
+ import {
6
+ handleHealth,
7
+ handleReady,
8
+ handleRemoteHealth,
9
+ } from "./healthEndpoints";
10
+ import { routeStudio } from "./studioRouter";
11
+ import { getDbStats } from "../../database/instrumentedDb";
12
+ import type { RequestStats } from "../RequestContext";
13
+
14
+ const logger = MainLogger.child({ scope: "App" });
15
+
16
+ function combineSignals(signals: AbortSignal[]): AbortSignal {
17
+ const anyFn = (AbortSignal as any).any;
18
+ if (typeof anyFn === 'function') {
19
+ return anyFn.call(AbortSignal, signals);
20
+ }
21
+ const controller = new AbortController();
22
+ for (const s of signals) {
23
+ if (s.aborted) {
24
+ controller.abort((s as any).reason);
25
+ return controller.signal;
26
+ }
27
+ s.addEventListener('abort', () => controller.abort((s as any).reason), { once: true });
28
+ }
29
+ return controller.signal;
30
+ }
31
+
32
+ export async function handleRequest(app: any, req: Request): Promise<Response> {
33
+ const url = new URL(req.url);
34
+ const method = req.method;
35
+ const startTime = Date.now();
36
+
37
+ if (method === 'OPTIONS') {
38
+ return new Response(null, {
39
+ status: 204,
40
+ headers: getCorsHeaders(app.config.cors, req),
41
+ });
42
+ }
43
+
44
+ // Request timeout — combine framework wall-clock with client abort signal
45
+ // and rebind onto the request so downstream handlers (Yoga, REST) see
46
+ // cancellation propagation (C05).
47
+ const controller = new AbortController();
48
+ const timeoutId = setTimeout(() => {
49
+ controller.abort(new Error(`Request timeout after 30000ms: ${method} ${url.pathname}`));
50
+ const stats = (req as any).__bunsaneStats as RequestStats | undefined;
51
+ logger.warn({
52
+ scope: 'App',
53
+ method,
54
+ path: url.pathname,
55
+ operationName: stats?.operationName,
56
+ dataLoaderCalls: stats?.dataLoaderCalls,
57
+ dbQueryCount: stats?.dbQueryCount,
58
+ msg: 'Request timeout',
59
+ }, `Request timeout: ${method} ${url.pathname}`);
60
+ }, 30000);
61
+ const combinedSignal = combineSignals([req.signal, controller.signal]);
62
+ req = new Request(req, { signal: combinedSignal });
63
+
64
+ const cors = app.config.cors;
65
+ const wrap = (response: Response) => addCorsHeaders(response, cors, req);
66
+
67
+ try {
68
+ if (url.pathname === "/health") {
69
+ const response = await handleHealth(app);
70
+ clearTimeout(timeoutId);
71
+ return wrap(response);
72
+ }
73
+
74
+ if (url.pathname === "/metrics") {
75
+ const metrics = await app.collectMetrics();
76
+ clearTimeout(timeoutId);
77
+ return wrap(new Response(JSON.stringify(metrics), {
78
+ status: 200,
79
+ headers: { "Content-Type": "application/json" },
80
+ }));
81
+ }
82
+
83
+ if (url.pathname === "/health/remote") {
84
+ const response = await handleRemoteHealth(app);
85
+ clearTimeout(timeoutId);
86
+ return wrap(response);
87
+ }
88
+
89
+ if (url.pathname === "/health/ready") {
90
+ const response = await handleReady(app);
91
+ clearTimeout(timeoutId);
92
+ return wrap(response);
93
+ }
94
+
95
+ if (url.pathname === "/openapi.json") {
96
+ clearTimeout(timeoutId);
97
+ return wrap(new Response(app.openAPISpecGenerator!.toJSON(), {
98
+ headers: { "Content-Type": "application/json" },
99
+ }));
100
+ }
101
+
102
+ if (url.pathname === "/docs") {
103
+ clearTimeout(timeoutId);
104
+ const swaggerUIHTML = `
105
+ <!DOCTYPE html>
106
+ <html>
107
+ <head>
108
+ <title>${app.name} Documentation</title>
109
+ <link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui.css" />
110
+ <style>
111
+ html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; }
112
+ *, *:before, *:after { box-sizing: inherit; }
113
+ body { margin: 0; background: #fafafa; }
114
+ </style>
115
+ </head>
116
+ <body>
117
+ <div id="swagger-ui"></div>
118
+ <script src="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui-bundle.js"></script>
119
+ <script>
120
+ window.onload = function() {
121
+ const ui = SwaggerUIBundle({
122
+ url: '/openapi.json',
123
+ dom_id: '#swagger-ui',
124
+ deepLinking: true,
125
+ presets: [
126
+ SwaggerUIBundle.presets.apis,
127
+ SwaggerUIBundle.presets.standalone
128
+ ],
129
+ plugins: [
130
+ SwaggerUIBundle.plugins.DownloadUrl
131
+ ],
132
+ layout: "BaseLayout"
133
+ });
134
+ };
135
+ </script>
136
+ </body>
137
+ </html>`;
138
+ return wrap(new Response(swaggerUIHTML, {
139
+ headers: { "Content-Type": "text/html" },
140
+ }));
141
+ }
142
+
143
+ const studioApiResponse = await routeStudio(app, url, req, method);
144
+ if (studioApiResponse) {
145
+ clearTimeout(timeoutId);
146
+ return wrap(studioApiResponse);
147
+ }
148
+
149
+ if (
150
+ app.studioEnabled &&
151
+ (url.pathname === "/studio" || url.pathname.startsWith("/studio/"))
152
+ ) {
153
+ clearTimeout(timeoutId);
154
+
155
+ if (url.pathname.startsWith("/studio/api/")) {
156
+ return wrap(new Response(
157
+ JSON.stringify({ error: "Studio API endpoint not found" }),
158
+ { status: 404, headers: { "Content-Type": "application/json" } },
159
+ ));
160
+ }
161
+
162
+ if (!url.pathname.startsWith("/studio/assets/")) {
163
+ const studioIndexPath = path.join(
164
+ import.meta.dirname,
165
+ "..",
166
+ "..",
167
+ "studio",
168
+ "dist",
169
+ "index.html",
170
+ );
171
+ try {
172
+ const studioFile = Bun.file(studioIndexPath);
173
+ if (await studioFile.exists()) {
174
+ let html = await studioFile.text();
175
+ const metadata = getSerializedMetadataStorage();
176
+ const metadataScript = `<script>window.bunsaneMetadata = ${JSON.stringify(metadata)};</script>`;
177
+ html = html.replace("</head>", `${metadataScript}</head>`);
178
+ return wrap(new Response(html, {
179
+ headers: { "Content-Type": "text/html" },
180
+ }));
181
+ } else {
182
+ return wrap(new Response(
183
+ "Studio not built. Run `bun run build:studio` to build the studio.",
184
+ { status: 404, headers: { "Content-Type": "text/plain" } },
185
+ ));
186
+ }
187
+ } catch (error) {
188
+ console.log("Error loading studio index.html:", error);
189
+ return wrap(new Response("Studio not available", {
190
+ status: 404,
191
+ headers: { "Content-Type": "text/plain" },
192
+ }));
193
+ }
194
+ }
195
+ }
196
+
197
+ for (const [route, folder] of app.staticAssets) {
198
+ if (url.pathname.startsWith(route)) {
199
+ const relativePath = url.pathname.slice(route.length);
200
+ const filePath = path.join(folder, relativePath);
201
+ try {
202
+ const file = Bun.file(filePath);
203
+ if (await file.exists()) {
204
+ clearTimeout(timeoutId);
205
+ return wrap(new Response(file));
206
+ }
207
+ } catch (error) {
208
+ logger.error(`Error serving static file ${filePath}:`, error as any);
209
+ }
210
+ }
211
+ }
212
+
213
+ const endpointKey = `${method}:${url.pathname}`;
214
+ let endpoint = app.restEndpointMap.get(endpointKey);
215
+
216
+ if (!endpoint) {
217
+ for (const ep of app.restEndpoints) {
218
+ if (ep.method !== method) continue;
219
+ const pattern = ep.path.replace(/:[^/]+/g, '[^/]+');
220
+ const regex = new RegExp(`^${pattern}$`);
221
+ if (regex.test(url.pathname)) {
222
+ endpoint = ep;
223
+ break;
224
+ }
225
+ }
226
+ }
227
+
228
+ if (endpoint) {
229
+ try {
230
+ const result = await endpoint.handler(req);
231
+ const duration = Date.now() - startTime;
232
+ logger.trace(`REST ${method} ${url.pathname} completed in ${duration}ms`);
233
+
234
+ clearTimeout(timeoutId);
235
+ if (result instanceof Response) {
236
+ return wrap(result);
237
+ } else {
238
+ return wrap(new Response(JSON.stringify(result), {
239
+ headers: { "Content-Type": "application/json" },
240
+ }));
241
+ }
242
+ } catch (error) {
243
+ const duration = Date.now() - startTime;
244
+ logger.error(
245
+ `Error in REST endpoint ${method} ${endpoint.path} after ${duration}ms`,
246
+ error as any,
247
+ );
248
+ clearTimeout(timeoutId);
249
+ return wrap(new Response(
250
+ JSON.stringify({
251
+ error: "Internal server error",
252
+ code: "INTERNAL_ERROR",
253
+ ...(process.env.NODE_ENV === 'development' && {
254
+ message: (error as Error)?.message,
255
+ }),
256
+ }),
257
+ { status: 500, headers: { "Content-Type": "application/json" } },
258
+ ));
259
+ }
260
+ }
261
+
262
+ if (app.yoga) {
263
+ const response = await app.yoga(req);
264
+ const duration = Date.now() - startTime;
265
+ logger.trace(`GraphQL request completed in ${duration}ms`);
266
+ clearTimeout(timeoutId);
267
+ return response;
268
+ }
269
+
270
+ clearTimeout(timeoutId);
271
+ return wrap(new Response("Not Found", { status: 404 }));
272
+ } catch (error) {
273
+ const duration = Date.now() - startTime;
274
+ const stats = (req as any).__bunsaneStats as RequestStats | undefined;
275
+ logger.error(
276
+ {
277
+ scope: 'App',
278
+ method,
279
+ path: url.pathname,
280
+ duration,
281
+ operationName: stats?.operationName,
282
+ dataLoaderCalls: stats?.dataLoaderCalls,
283
+ dbQueryCount: stats?.dbQueryCount,
284
+ dbStats: getDbStats(),
285
+ err: error,
286
+ },
287
+ `Request failed after ${duration}ms: ${method} ${url.pathname}`,
288
+ );
289
+ clearTimeout(timeoutId);
290
+
291
+ if ((error as Error).name === "AbortError") {
292
+ return wrap(new Response(
293
+ JSON.stringify({ error: "Request timeout", code: "TIMEOUT_ERROR" }),
294
+ { status: 408, headers: { "Content-Type": "application/json" } },
295
+ ));
296
+ }
297
+
298
+ return wrap(new Response(
299
+ JSON.stringify({
300
+ error: "Internal server error",
301
+ code: "INTERNAL_ERROR",
302
+ ...(process.env.NODE_ENV === 'development' && {
303
+ message: (error as Error)?.message,
304
+ }),
305
+ }),
306
+ { status: 500, headers: { "Content-Type": "application/json" } },
307
+ ));
308
+ }
309
+ }
@@ -0,0 +1,72 @@
1
+ import { logger as MainLogger } from "../Logger";
2
+
3
+ const logger = MainLogger.child({ scope: "App" });
4
+
5
+ export function collectRestEndpoints(app: any, services: any[]): void {
6
+ for (const service of services) {
7
+ const endpoints = (service.constructor as any).httpEndpoints;
8
+ if (!endpoints) continue;
9
+
10
+ for (const endpoint of endpoints) {
11
+ const endpointInfo = {
12
+ method: endpoint.method,
13
+ path: endpoint.path,
14
+ handler: endpoint.handler.bind(service),
15
+ service: service,
16
+ };
17
+ logger.trace(
18
+ `Registered REST endpoint: [${endpoint.method}] ${endpoint.path} for service ${service.constructor.name}`,
19
+ );
20
+ app.restEndpoints.push(endpointInfo);
21
+ app.restEndpointMap.set(`${endpoint.method}:${endpoint.path}`, endpointInfo);
22
+
23
+ if ((endpoint.handler as any).swaggerOperation) {
24
+ const classTags = (service.constructor as any).swaggerClassTags || [];
25
+ const methodTags =
26
+ (service.constructor as any).swaggerMethodTags?.[endpoint.handler.name] || [];
27
+ const allTags = [...classTags, ...methodTags];
28
+
29
+ logger.trace(
30
+ `Generating OpenAPI spec for endpoint: [${endpoint.method}] ${endpoint.path} with tags: ${allTags.join(", ")}`,
31
+ );
32
+
33
+ const operation = { ...(endpoint.handler as any).swaggerOperation };
34
+ if (allTags.length > 0) {
35
+ operation.tags = [...(operation.tags || []), ...allTags];
36
+ }
37
+
38
+ app.openAPISpecGenerator!.addEndpoint({
39
+ method: endpoint.method,
40
+ path: endpoint.path,
41
+ operation,
42
+ });
43
+ logger.trace(
44
+ `Registered OpenAPI spec for endpoint: [${endpoint.method}] ${endpoint.path}`,
45
+ );
46
+ } else if (app.enforceDocs) {
47
+ logger.warn(
48
+ `No swagger operation found for endpoint: [${endpoint.method}] ${endpoint.path} in service ${service.constructor.name}`,
49
+ );
50
+ app.openAPISpecGenerator!.addEndpoint({
51
+ method: endpoint.method,
52
+ path: endpoint.path,
53
+ operation: {
54
+ summary: `No description for ${endpoint.path}. Don't use this endpoint until it's properly documented!`,
55
+ requestBody: {
56
+ content: {
57
+ "application/json": {
58
+ schema: {},
59
+ },
60
+ },
61
+ },
62
+ responses: {
63
+ "200": {
64
+ description: "Success",
65
+ },
66
+ },
67
+ },
68
+ });
69
+ }
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,97 @@
1
+ import ApplicationLifecycle from "../ApplicationLifecycle";
2
+ import { logger as MainLogger } from "../Logger";
3
+ import { SchedulerManager } from "../SchedulerManager";
4
+ import db from "../../database";
5
+ import { setRemoteManager } from "../remote";
6
+
7
+ const logger = MainLogger.child({ scope: "App" });
8
+
9
+ export async function runShutdown(app: any): Promise<void> {
10
+ if (app.isShuttingDown) return;
11
+ app.isShuttingDown = true;
12
+ app.isReady = false;
13
+
14
+ const shutdownStart = Date.now();
15
+ logger.info({ scope: 'app', component: 'App', msg: 'Shutting down application', gracePeriodMs: app.shutdownGracePeriod });
16
+
17
+ const budgetRemaining = () => Math.max(500, app.shutdownGracePeriod - (Date.now() - shutdownStart));
18
+
19
+ if (app.server) {
20
+ try {
21
+ logger.info({ scope: 'app', component: 'App', msg: 'Draining HTTP connections' });
22
+ app.server.stop(false);
23
+ await waitForHttpDrain(app, budgetRemaining());
24
+ try { app.server.stop(true); } catch {}
25
+ logger.info({ scope: 'app', component: 'App', msg: 'HTTP server stopped' });
26
+ } catch (error) {
27
+ logger.warn({ scope: 'app', component: 'App', msg: 'HTTP server stop error', err: error });
28
+ }
29
+ }
30
+
31
+ try {
32
+ await SchedulerManager.getInstance().stop(Math.min(budgetRemaining(), 15_000));
33
+ logger.info({ scope: 'app', component: 'App', msg: 'Scheduler stopped' });
34
+ } catch (error) {
35
+ logger.warn({ scope: 'app', component: 'App', msg: 'Scheduler stop error', err: error });
36
+ }
37
+
38
+ if (app.remote) {
39
+ try {
40
+ await app.remote.shutdown();
41
+ setRemoteManager(null);
42
+ app.remote = null;
43
+ logger.info({ scope: 'app', component: 'App', msg: 'RemoteManager shutdown' });
44
+ } catch (error) {
45
+ logger.warn({ scope: 'app', component: 'App', msg: 'RemoteManager shutdown error', err: error });
46
+ }
47
+ }
48
+
49
+ try {
50
+ const { Entity } = await import('../Entity');
51
+ await Entity.drainPendingCacheOps(Math.min(budgetRemaining(), 5_000));
52
+ await Entity.drainPendingSideEffects(Math.min(budgetRemaining(), 5_000));
53
+ } catch (error) {
54
+ logger.warn({ scope: 'cache', component: 'App', msg: 'Entity cache op drain error', err: error });
55
+ }
56
+
57
+ try {
58
+ const { CacheManager } = await import('../cache/CacheManager');
59
+ await CacheManager.getInstance().shutdown();
60
+ logger.info({ scope: 'cache', component: 'App', msg: 'Cache shutdown completed' });
61
+ } catch (error) {
62
+ logger.warn({ scope: 'cache', component: 'App', msg: 'Cache shutdown error', err: error });
63
+ }
64
+
65
+ try {
66
+ db.close();
67
+ logger.info({ scope: 'app', component: 'App', msg: 'Database pool closed' });
68
+ } catch (error) {
69
+ logger.warn({ scope: 'app', component: 'App', msg: 'Database pool close error', err: error });
70
+ }
71
+
72
+ try {
73
+ if (app.phaseListener) {
74
+ ApplicationLifecycle.removePhaseListener(app.phaseListener);
75
+ app.phaseListener = null;
76
+ }
77
+ SchedulerManager.getInstance().disposeLifecycleIntegration();
78
+ } catch { /* ignore */ }
79
+
80
+ app.unregisterProcessHandlers();
81
+
82
+ logger.info({ scope: 'app', component: 'App', msg: 'Application shutdown completed', durationMs: Date.now() - shutdownStart });
83
+ }
84
+
85
+ export async function waitForHttpDrain(app: any, timeoutMs: number): Promise<void> {
86
+ if (!app.server) return;
87
+ const deadline = Date.now() + timeoutMs;
88
+ while (Date.now() < deadline) {
89
+ const pending = (app.server as any).pendingRequests ?? 0;
90
+ if (pending === 0) return;
91
+ await new Promise((r) => setTimeout(r, 50));
92
+ }
93
+ const leftover = (app.server as any).pendingRequests ?? -1;
94
+ if (leftover > 0) {
95
+ logger.warn({ scope: 'app', component: 'App', msg: 'HTTP drain timeout, pending requests remaining', pendingRequests: leftover });
96
+ }
97
+ }
@@ -0,0 +1,83 @@
1
+ import studioEndpoint from "../../endpoints";
2
+
3
+ export async function routeStudio(
4
+ app: any,
5
+ url: URL,
6
+ req: Request,
7
+ method: string,
8
+ ): Promise<Response | null> {
9
+ if (!app.studioEnabled || !url.pathname.startsWith("/studio/api/")) return null;
10
+
11
+ if (url.pathname === "/studio/api/tables") {
12
+ return await studioEndpoint.getTables();
13
+ }
14
+
15
+ if (url.pathname === "/studio/api/stats") {
16
+ return await studioEndpoint.handleStudioStatsRequest();
17
+ }
18
+
19
+ if (url.pathname === "/studio/api/components") {
20
+ return await studioEndpoint.handleStudioComponentsRequest();
21
+ }
22
+
23
+ if (url.pathname === "/studio/api/query" && method === "POST") {
24
+ const body = await req.json();
25
+ return await studioEndpoint.handleStudioQueryRequest(body);
26
+ }
27
+
28
+ const studioApiPath = url.pathname.replace("/studio/api/", "");
29
+ const pathSegments = studioApiPath.split("/");
30
+
31
+ if (pathSegments[0] === "entity" && pathSegments[1]) {
32
+ const entityId = pathSegments[1];
33
+ return await studioEndpoint.handleEntityInspectorRequest(entityId);
34
+ }
35
+
36
+ if (pathSegments[0] === "table" && pathSegments[1]) {
37
+ const tableName = pathSegments[1];
38
+
39
+ if (method === "DELETE") {
40
+ const body = await req.json();
41
+ return await studioEndpoint.handleStudioTableDeleteRequest(tableName, body);
42
+ }
43
+
44
+ const limit = url.searchParams.get("limit");
45
+ const offset = url.searchParams.get("offset");
46
+ const search = url.searchParams.get("search");
47
+
48
+ return await studioEndpoint.handleStudioTableRequest(tableName, {
49
+ limit: limit ? parseInt(limit, 10) : undefined,
50
+ offset: offset ? parseInt(offset, 10) : undefined,
51
+ search: search ?? undefined,
52
+ });
53
+ }
54
+
55
+ if (pathSegments[0] === "arche-type" && pathSegments[1]) {
56
+ const archeTypeName = pathSegments[1];
57
+
58
+ if (method === "DELETE") {
59
+ const body = await req.json();
60
+ return await studioEndpoint.handleStudioArcheTypeDeleteRequest(archeTypeName, body);
61
+ }
62
+
63
+ const limit = url.searchParams.get("limit");
64
+ const offset = url.searchParams.get("offset");
65
+ const search = url.searchParams.get("search");
66
+ const includeDeleted = url.searchParams.get("include_deleted");
67
+
68
+ return await studioEndpoint.handleStudioArcheTypeRecordsRequest(archeTypeName, {
69
+ limit: limit ? parseInt(limit, 10) : undefined,
70
+ offset: offset ? parseInt(offset, 10) : undefined,
71
+ search: search ?? undefined,
72
+ include_deleted: includeDeleted === "true",
73
+ });
74
+ }
75
+
76
+ return new Response(
77
+ JSON.stringify({ error: "Studio API endpoint not found" }),
78
+ {
79
+ status: 404,
80
+ headers: { "Content-Type": "application/json" },
81
+ },
82
+ );
83
+ }
@@ -0,0 +1,100 @@
1
+ import { z, ZodObject } from "zod";
2
+ import { asObjectType } from "@gqloom/zod";
3
+
4
+ export const customTypeRegistry = new Map<any, any>();
5
+ export const customTypeNameRegistry = new Map<any, string>();
6
+ export const registeredCustomTypes = new Map<string, any>();
7
+ export const customTypeSilks = new Map<string, any>();
8
+ export const customTypeResolvers: any[] = [];
9
+ export const inputTypeRegistry = new Map<any, string>();
10
+
11
+ // Structural signature registry for input type deduplication
12
+ // Maps structural signature -> registered input type name
13
+ export const structuralSignatureRegistry = new Map<string, string>();
14
+
15
+ let _generateZodStructuralSignature: ((schema: any) => string) | null = null;
16
+
17
+ function getSignatureGenerator(): (schema: any) => string {
18
+ if (!_generateZodStructuralSignature) {
19
+ const { generateZodStructuralSignature } = require('../../gql/utils/TypeSignature');
20
+ _generateZodStructuralSignature = generateZodStructuralSignature;
21
+ }
22
+ return _generateZodStructuralSignature!;
23
+ }
24
+
25
+ export function registerCustomZodType(
26
+ type: any,
27
+ schema: any,
28
+ typeName?: string,
29
+ inputTypeName?: string
30
+ ) {
31
+ if (typeName && schema instanceof ZodObject) {
32
+ const shape = schema.shape;
33
+ const namedSchema = z.object({
34
+ __typename: z.literal(typeName).nullish(),
35
+ ...shape,
36
+ });
37
+ customTypeRegistry.set(type, namedSchema);
38
+ if (typeName) {
39
+ customTypeNameRegistry.set(type, typeName);
40
+ registeredCustomTypes.set(typeName, namedSchema);
41
+ }
42
+
43
+ if (inputTypeName) {
44
+ const inputSchema = z.object(shape).register(asObjectType, { name: inputTypeName });
45
+ registeredCustomTypes.set(inputTypeName, inputSchema);
46
+ inputTypeRegistry.set(type, inputTypeName);
47
+
48
+ try {
49
+ const generateSignature = getSignatureGenerator();
50
+ const signature = generateSignature(z.object(shape));
51
+ structuralSignatureRegistry.set(signature, inputTypeName);
52
+ } catch (e) {
53
+ // Signature registration is optional, don't fail if it errors
54
+ }
55
+ }
56
+ } else {
57
+ customTypeRegistry.set(type, schema);
58
+ if (typeName) {
59
+ customTypeNameRegistry.set(type, typeName);
60
+ registeredCustomTypes.set(typeName, schema);
61
+ }
62
+
63
+ if (inputTypeName && schema instanceof ZodObject) {
64
+ const inputSchema = schema.register(asObjectType, { name: inputTypeName });
65
+ registeredCustomTypes.set(inputTypeName, inputSchema);
66
+ inputTypeRegistry.set(type, inputTypeName);
67
+
68
+ try {
69
+ const generateSignature = getSignatureGenerator();
70
+ const signature = generateSignature(schema);
71
+ structuralSignatureRegistry.set(signature, inputTypeName);
72
+ } catch (e) {
73
+ // Signature registration is optional, don't fail if it errors
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ export function getRegisteredCustomTypes() {
80
+ return registeredCustomTypes;
81
+ }
82
+
83
+ /**
84
+ * Find a matching registered input type for a given Zod schema based on structural equivalence.
85
+ */
86
+ export function findMatchingInputType(schema: any): string | null {
87
+ if (!schema) return null;
88
+
89
+ try {
90
+ const generateSignature = getSignatureGenerator();
91
+ const signature = generateSignature(schema);
92
+ return structuralSignatureRegistry.get(signature) || null;
93
+ } catch (e) {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ export function getStructuralSignatureRegistry(): Map<string, string> {
99
+ return structuralSignatureRegistry;
100
+ }