bunsane 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/CHANGELOG.md +52 -0
  3. package/config/cache.config.ts +35 -1
  4. package/core/App.ts +24 -1064
  5. package/core/ArcheType.ts +78 -2110
  6. package/core/Entity.ts +10 -33
  7. package/core/RequestContext.ts +85 -36
  8. package/core/RequestLoaders.ts +89 -31
  9. package/core/app/bootstrap.ts +133 -0
  10. package/core/app/cors.ts +94 -0
  11. package/core/app/graphqlSetup.ts +56 -0
  12. package/core/app/healthEndpoints.ts +31 -0
  13. package/core/app/metricsCollector.ts +27 -0
  14. package/core/app/preparedStatementWarmup.ts +55 -0
  15. package/core/app/processHandlers.ts +43 -0
  16. package/core/app/requestRouter.ts +309 -0
  17. package/core/app/restRegistry.ts +72 -0
  18. package/core/app/shutdown.ts +97 -0
  19. package/core/app/studioRouter.ts +83 -0
  20. package/core/archetype/customTypes.ts +100 -0
  21. package/core/archetype/decorators.ts +171 -0
  22. package/core/archetype/fieldResolvers.ts +621 -0
  23. package/core/archetype/helpers.ts +29 -0
  24. package/core/archetype/relationLoader.ts +118 -0
  25. package/core/archetype/schemaBuilder.ts +141 -0
  26. package/core/archetype/weaver.ts +218 -0
  27. package/core/archetype/zodSchemaBuilder.ts +527 -0
  28. package/core/cache/CacheManager.ts +126 -9
  29. package/core/middleware/AccessLog.ts +8 -1
  30. package/database/PreparedStatementCache.ts +12 -3
  31. package/database/cancellable.ts +22 -0
  32. package/database/instrumentedDb.ts +141 -0
  33. package/docs/RFC_APP_REFACTOR.md +248 -0
  34. package/docs/RFC_REFACTOR_TARGETS.md +251 -0
  35. package/package.json +1 -1
  36. package/query/Query.ts +53 -20
  37. package/tests/integration/loaders/RequestLoaders.abort.test.ts +82 -0
  38. package/tests/integration/query/Query.abort.test.ts +66 -0
  39. package/tests/unit/cache/CacheManager.test.ts +132 -1
  40. package/tests/unit/database/cancellable.test.ts +81 -0
  41. package/tests/unit/database/instrumentedDb.test.ts +160 -0
@@ -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
+ }
@@ -0,0 +1,171 @@
1
+ import type { BaseComponent } from "../components";
2
+ import type { ArcheTypeFieldOptions } from "../metadata/definitions/ArcheType";
3
+ import type { BaseArcheType, ArcheTypeOptions, RelationOptions } from "../ArcheType";
4
+ import { getMetadataStorage } from "../metadata";
5
+ import "reflect-metadata";
6
+
7
+ export const archetypeFunctionsSymbol = Symbol.for("bunsane:archetypeFunctions");
8
+ export const archetypeFieldsSymbol = Symbol.for("bunsane:archetypeFields");
9
+ export const archetypeUnionFieldsSymbol = Symbol.for("bunsane:archetypeUnionFields");
10
+ export const archetypeRelationsSymbol = Symbol.for("bunsane:archetypeRelations");
11
+
12
+ export function ArcheTypeFunction(options?: {
13
+ returnType?: string;
14
+ args?: Array<{
15
+ name: string;
16
+ type: any;
17
+ nullable?: boolean;
18
+ }>;
19
+ }) {
20
+ return function (target: any, propertyKey: string) {
21
+ if (!target[archetypeFunctionsSymbol]) {
22
+ target[archetypeFunctionsSymbol] = [];
23
+ }
24
+ target[archetypeFunctionsSymbol].push({ propertyKey, options });
25
+ };
26
+ }
27
+
28
+ export function ArcheType<T extends new () => BaseArcheType>(
29
+ nameOrOptions?: string | ArcheTypeOptions
30
+ ) {
31
+ return function (target: T): T {
32
+ const storage = getMetadataStorage();
33
+ const typeId = storage.getComponentId(target.name);
34
+
35
+ let archetype_name = target.name;
36
+
37
+ if (typeof nameOrOptions === "string") {
38
+ archetype_name = nameOrOptions;
39
+ } else if (nameOrOptions) {
40
+ archetype_name = nameOrOptions.name || target.name;
41
+ }
42
+
43
+ storage.collectArcheTypeMetadata({
44
+ name: archetype_name,
45
+ typeId: typeId,
46
+ target: target,
47
+ });
48
+
49
+ const prototype = target.prototype;
50
+ const fields = prototype[archetypeFieldsSymbol];
51
+ if (fields) {
52
+ for (const { propertyKey, component, options } of fields) {
53
+ const type = Reflect.getMetadata(
54
+ "design:type",
55
+ target.prototype,
56
+ propertyKey
57
+ );
58
+ storage.collectArchetypeField(
59
+ archetype_name,
60
+ propertyKey,
61
+ component,
62
+ options,
63
+ type
64
+ );
65
+ }
66
+ }
67
+
68
+ const unions = prototype[archetypeUnionFieldsSymbol];
69
+ if (unions) {
70
+ for (const { propertyKey, components, options } of unions) {
71
+ storage.collectArchetypeUnion(
72
+ archetype_name,
73
+ propertyKey,
74
+ components,
75
+ options,
76
+ "union"
77
+ );
78
+ }
79
+ }
80
+
81
+ const relations = prototype[archetypeRelationsSymbol];
82
+ if (relations) {
83
+ for (const {
84
+ propertyKey,
85
+ relatedArcheType,
86
+ relationType,
87
+ options,
88
+ } of relations) {
89
+ const type = Reflect.getMetadata(
90
+ "design:type",
91
+ target.prototype,
92
+ propertyKey
93
+ );
94
+ storage.collectArchetypeRelation(
95
+ archetype_name,
96
+ propertyKey,
97
+ relatedArcheType,
98
+ relationType,
99
+ options,
100
+ type
101
+ );
102
+ }
103
+ }
104
+
105
+ const functions = prototype[archetypeFunctionsSymbol];
106
+ if (functions) {
107
+ storage.collectArcheTypeMetadata({
108
+ name: archetype_name,
109
+ typeId: typeId,
110
+ target: target,
111
+ functions: functions,
112
+ });
113
+ }
114
+
115
+ return target;
116
+ };
117
+ }
118
+
119
+ export function ArcheTypeField<T extends BaseComponent>(
120
+ component: new (...args: any[]) => T,
121
+ options?: ArcheTypeFieldOptions
122
+ ) {
123
+ return function (target: any, propertyKey: string) {
124
+ if (!target[archetypeFieldsSymbol]) {
125
+ target[archetypeFieldsSymbol] = [];
126
+ }
127
+ target[archetypeFieldsSymbol].push({ propertyKey, component, options });
128
+ };
129
+ }
130
+
131
+ export function ArcheTypeUnionField(
132
+ components: (new (...args: any[]) => any)[],
133
+ options?: ArcheTypeFieldOptions
134
+ ) {
135
+ return function (target: any, propertyKey: string) {
136
+ if (!target[archetypeUnionFieldsSymbol]) {
137
+ target[archetypeUnionFieldsSymbol] = [];
138
+ }
139
+ target[archetypeUnionFieldsSymbol].push({
140
+ propertyKey,
141
+ components,
142
+ options,
143
+ });
144
+ };
145
+ }
146
+
147
+ function createRelationDecorator(
148
+ relationType: "hasMany" | "belongsTo" | "hasOne" | "belongsToMany"
149
+ ) {
150
+ return function (relatedArcheType: string, options?: RelationOptions) {
151
+ return function (target: any, propertyKey: string) {
152
+ if (!target[archetypeRelationsSymbol]) {
153
+ target[archetypeRelationsSymbol] = [];
154
+ }
155
+ target[archetypeRelationsSymbol].push({
156
+ propertyKey,
157
+ relatedArcheType,
158
+ relationType,
159
+ options,
160
+ });
161
+ };
162
+ };
163
+ }
164
+
165
+ export const HasMany = createRelationDecorator("hasMany");
166
+ export const BelongsTo = createRelationDecorator("belongsTo");
167
+ export const HasOne = createRelationDecorator("hasOne");
168
+ export const BelongsToMany = createRelationDecorator("belongsToMany");
169
+
170
+ // Keep ArcheTypeRelation as alias for backwards compatibility
171
+ export const ArcheTypeRelation = HasMany;