bunsane 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy-docs.yml +57 -0
- package/LICENSE.md +1 -1
- package/README.md +2 -28
- package/TODO.md +8 -1
- package/bun.lock +3 -0
- package/config/upload.config.ts +135 -0
- package/core/App.ts +168 -4
- package/core/ArcheType.ts +122 -0
- package/core/BatchLoader.ts +100 -0
- package/core/ComponentRegistry.ts +4 -3
- package/core/Components.ts +2 -2
- package/core/Decorators.ts +15 -8
- package/core/Entity.ts +193 -14
- package/core/EntityCache.ts +15 -0
- package/core/EntityHookManager.ts +855 -0
- package/core/EntityManager.ts +12 -2
- package/core/ErrorHandler.ts +64 -7
- package/core/FileValidator.ts +284 -0
- package/core/Query.ts +503 -85
- package/core/RequestContext.ts +24 -0
- package/core/RequestLoaders.ts +89 -0
- package/core/SchedulerManager.ts +710 -0
- package/core/UploadManager.ts +261 -0
- package/core/components/UploadComponent.ts +206 -0
- package/core/decorators/EntityHooks.ts +190 -0
- package/core/decorators/ScheduledTask.ts +83 -0
- package/core/events/EntityLifecycleEvents.ts +177 -0
- package/core/processors/ImageProcessor.ts +423 -0
- package/core/storage/LocalStorageProvider.ts +290 -0
- package/core/storage/StorageProvider.ts +112 -0
- package/database/DatabaseHelper.ts +183 -58
- package/database/index.ts +5 -5
- package/database/sqlHelpers.ts +7 -0
- package/docs/README.md +149 -0
- package/docs/_coverpage.md +36 -0
- package/docs/_sidebar.md +23 -0
- package/docs/api/core.md +568 -0
- package/docs/api/hooks.md +554 -0
- package/docs/api/index.md +222 -0
- package/docs/api/query.md +678 -0
- package/docs/api/service.md +744 -0
- package/docs/core-concepts/archetypes.md +512 -0
- package/docs/core-concepts/components.md +498 -0
- package/docs/core-concepts/entity.md +314 -0
- package/docs/core-concepts/hooks.md +683 -0
- package/docs/core-concepts/query.md +588 -0
- package/docs/core-concepts/services.md +647 -0
- package/docs/examples/code-examples.md +425 -0
- package/docs/getting-started.md +337 -0
- package/docs/index.html +97 -0
- package/gql/Generator.ts +58 -35
- package/gql/decorators/Upload.ts +176 -0
- package/gql/helpers.ts +67 -0
- package/gql/index.ts +65 -31
- package/gql/types.ts +1 -1
- package/index.ts +79 -11
- package/package.json +19 -10
- package/rest/Generator.ts +3 -0
- package/rest/index.ts +22 -0
- package/service/Service.ts +1 -1
- package/service/ServiceRegistry.ts +10 -6
- package/service/index.ts +12 -1
- package/tests/bench/insert.bench.ts +59 -0
- package/tests/bench/relations.bench.ts +269 -0
- package/tests/bench/sorting.bench.ts +415 -0
- package/tests/component-hooks.test.ts +1409 -0
- package/tests/component.test.ts +338 -0
- package/tests/errorHandling.test.ts +155 -0
- package/tests/hooks.test.ts +666 -0
- package/tests/query-sorting.test.ts +101 -0
- package/tests/relations.test.ts +169 -0
- package/tests/scheduler.test.ts +724 -0
- package/tsconfig.json +35 -34
- package/types/graphql.types.ts +87 -0
- package/types/hooks.types.ts +141 -0
- package/types/scheduler.types.ts +165 -0
- package/types/upload.types.ts +184 -0
- package/upload/index.ts +140 -0
- package/utils/UploadHelper.ts +305 -0
- package/utils/cronParser.ts +366 -0
- package/utils/errorMessages.ts +151 -0
- package/core/Events.ts +0 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
import type { UploadDecoratorConfig } from "../../types/upload.types";
|
|
3
|
+
import { UploadManager } from "../../core/UploadManager";
|
|
4
|
+
import { logger as MainLogger } from "../../core/Logger";
|
|
5
|
+
|
|
6
|
+
const logger = MainLogger.child({ scope: "UploadDecorator" });
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Metadata key for upload configuration
|
|
10
|
+
*/
|
|
11
|
+
export const UPLOAD_CONFIG_KEY = Symbol("upload:config");
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @Upload decorator for GraphQL mutation parameters
|
|
15
|
+
* Automatically handles file uploads and stores metadata
|
|
16
|
+
*/
|
|
17
|
+
export function Upload(config?: UploadDecoratorConfig) {
|
|
18
|
+
return function (target: any, propertyKey: string, parameterIndex: number) {
|
|
19
|
+
logger.trace(`Registering @Upload decorator for ${target.constructor.name}.${propertyKey} parameter ${parameterIndex}`);
|
|
20
|
+
|
|
21
|
+
const existingMetadata = Reflect.getMetadata(UPLOAD_CONFIG_KEY, target, propertyKey) || {};
|
|
22
|
+
|
|
23
|
+
existingMetadata[parameterIndex] = {
|
|
24
|
+
field: config?.field || propertyKey,
|
|
25
|
+
batch: config?.batch || false,
|
|
26
|
+
required: config?.required || false,
|
|
27
|
+
validationMessage: config?.validationMessage,
|
|
28
|
+
...config
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
Reflect.defineMetadata(UPLOAD_CONFIG_KEY, existingMetadata, target, propertyKey);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @UploadField decorator for GraphQL field-level upload configuration
|
|
37
|
+
* Used to configure upload behavior for specific fields
|
|
38
|
+
*/
|
|
39
|
+
export function UploadField(config: UploadDecoratorConfig) {
|
|
40
|
+
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
|
41
|
+
logger.trace(`Registering @UploadField decorator for ${target.constructor.name}.${propertyKey}`);
|
|
42
|
+
|
|
43
|
+
const originalMethod = descriptor.value;
|
|
44
|
+
|
|
45
|
+
descriptor.value = async function (...args: any[]) {
|
|
46
|
+
const uploadManager = UploadManager.getInstance();
|
|
47
|
+
|
|
48
|
+
// Check if this method has upload parameters
|
|
49
|
+
const uploadMetadata = Reflect.getMetadata(UPLOAD_CONFIG_KEY, target, propertyKey);
|
|
50
|
+
|
|
51
|
+
if (uploadMetadata) {
|
|
52
|
+
// Process uploads before calling the original method
|
|
53
|
+
for (const [paramIndex, uploadConfig] of Object.entries(uploadMetadata)) {
|
|
54
|
+
const paramIdx = parseInt(paramIndex);
|
|
55
|
+
const file = args[paramIdx];
|
|
56
|
+
const config = uploadConfig as any;
|
|
57
|
+
|
|
58
|
+
if (file && file instanceof File) {
|
|
59
|
+
logger.info(`Processing upload for parameter ${paramIdx} in ${target.constructor.name}.${propertyKey}`);
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const result = await uploadManager.uploadFile(file, config);
|
|
63
|
+
|
|
64
|
+
if (!result.success) {
|
|
65
|
+
throw new Error(config.validationMessage || result.error?.message || "Upload failed");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Replace file parameter with upload result
|
|
69
|
+
args[paramIdx] = result;
|
|
70
|
+
|
|
71
|
+
} catch (error) {
|
|
72
|
+
logger.error(`Upload failed for ${target.constructor.name}.${propertyKey}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
} else if (config.required) {
|
|
76
|
+
throw new Error(`Required upload file missing for parameter ${paramIdx}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return await originalMethod.apply(this, args);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return descriptor;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Helper function to extract upload configuration from method metadata
|
|
90
|
+
*/
|
|
91
|
+
export function getUploadConfiguration(target: any, propertyKey: string): Record<number, any> | undefined {
|
|
92
|
+
return Reflect.getMetadata(UPLOAD_CONFIG_KEY, target, propertyKey);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @BatchUpload decorator for handling multiple file uploads
|
|
97
|
+
*/
|
|
98
|
+
export function BatchUpload(config?: UploadDecoratorConfig) {
|
|
99
|
+
return Upload({ ...config, batch: true });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @RequiredUpload decorator for mandatory file uploads
|
|
104
|
+
*/
|
|
105
|
+
export function RequiredUpload(config?: UploadDecoratorConfig) {
|
|
106
|
+
return Upload({ ...config, required: true });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Higher-order decorator factory for common upload patterns
|
|
111
|
+
*/
|
|
112
|
+
export class UploadDecorators {
|
|
113
|
+
/**
|
|
114
|
+
* Image upload decorator with image-specific validation
|
|
115
|
+
*/
|
|
116
|
+
static Image(config?: Partial<UploadDecoratorConfig>) {
|
|
117
|
+
return Upload({
|
|
118
|
+
...config,
|
|
119
|
+
maxFileSize: 5 * 1024 * 1024, // 5MB
|
|
120
|
+
allowedMimeTypes: ["image/jpeg", "image/png", "image/gif", "image/webp"],
|
|
121
|
+
allowedExtensions: [".jpg", ".jpeg", ".png", ".gif", ".webp"],
|
|
122
|
+
generateThumbnails: true
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Avatar upload decorator with strict constraints
|
|
128
|
+
*/
|
|
129
|
+
static Avatar(config?: Partial<UploadDecoratorConfig>) {
|
|
130
|
+
return Upload({
|
|
131
|
+
...config,
|
|
132
|
+
required: true,
|
|
133
|
+
maxFileSize: 2 * 1024 * 1024, // 2MB
|
|
134
|
+
allowedMimeTypes: ["image/jpeg", "image/png", "image/webp"],
|
|
135
|
+
allowedExtensions: [".jpg", ".jpeg", ".png", ".webp"],
|
|
136
|
+
generateThumbnails: true,
|
|
137
|
+
namingStrategy: "uuid"
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Document upload decorator
|
|
143
|
+
*/
|
|
144
|
+
static Document(config?: Partial<UploadDecoratorConfig>) {
|
|
145
|
+
return Upload({
|
|
146
|
+
...config,
|
|
147
|
+
maxFileSize: 25 * 1024 * 1024, // 25MB
|
|
148
|
+
allowedMimeTypes: [
|
|
149
|
+
"application/pdf",
|
|
150
|
+
"text/plain",
|
|
151
|
+
"application/msword",
|
|
152
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
153
|
+
],
|
|
154
|
+
allowedExtensions: [".pdf", ".txt", ".doc", ".docx"],
|
|
155
|
+
validateFileSignature: true
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Secure upload decorator with strict validation
|
|
161
|
+
*/
|
|
162
|
+
static Secure(config?: Partial<UploadDecoratorConfig>) {
|
|
163
|
+
return Upload({
|
|
164
|
+
...config,
|
|
165
|
+
maxFileSize: 1 * 1024 * 1024, // 1MB
|
|
166
|
+
allowedMimeTypes: ["image/jpeg", "image/png"],
|
|
167
|
+
allowedExtensions: [".jpg", ".jpeg", ".png"],
|
|
168
|
+
validateFileSignature: true,
|
|
169
|
+
sanitizeFileName: true,
|
|
170
|
+
validation: {
|
|
171
|
+
scanForMalware: true,
|
|
172
|
+
strictMimeType: true
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
package/gql/helpers.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { GraphQLFieldTypes } from './types';
|
|
2
|
+
|
|
3
|
+
export type GraphQLType =
|
|
4
|
+
| GraphQLFieldTypes // e.g., "ID!", "String"
|
|
5
|
+
| string // For custom types like "User", "[User]", "[User]!"
|
|
6
|
+
| `${string}!` // For required custom types
|
|
7
|
+
| `[${string}]` // For list types
|
|
8
|
+
| `[${string}]!`; // For required list types
|
|
9
|
+
|
|
10
|
+
export function isValidGraphQLType(type: string): type is GraphQLType {
|
|
11
|
+
const enumValues = Object.values(GraphQLFieldTypes);
|
|
12
|
+
return enumValues.includes(type as GraphQLFieldTypes) ||
|
|
13
|
+
/^(\w+|\[\w+\])(!)?$/.test(type); // Simple regex for custom types/lists
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type TypeFromGraphQL<T extends GraphQLType> =
|
|
17
|
+
T extends GraphQLFieldTypes.ID_REQUIRED | GraphQLFieldTypes.ID_OPTIONAL ? string :
|
|
18
|
+
T extends GraphQLFieldTypes.STRING_REQUIRED ? string :
|
|
19
|
+
T extends GraphQLFieldTypes.STRING_OPTIONAL ? string | null :
|
|
20
|
+
T extends GraphQLFieldTypes.INT_REQUIRED ? number :
|
|
21
|
+
T extends GraphQLFieldTypes.INT_OPTIONAL ? number | null :
|
|
22
|
+
T extends GraphQLFieldTypes.BOOLEAN_REQUIRED ? boolean :
|
|
23
|
+
T extends GraphQLFieldTypes.BOOLEAN_OPTIONAL ? boolean | null :
|
|
24
|
+
T extends GraphQLFieldTypes.FLOAT_REQUIRED ? number :
|
|
25
|
+
T extends GraphQLFieldTypes.FLOAT_OPTIONAL ? number | null :
|
|
26
|
+
T extends `[${string}]` | `[${string}]!` ? any[] :
|
|
27
|
+
any;
|
|
28
|
+
|
|
29
|
+
export type ResolverInput<T extends Record<string, GraphQLType>> = {
|
|
30
|
+
[K in keyof T]: TypeFromGraphQL<T[K]>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function isFieldRequested(info: any, fieldName: string): boolean {
|
|
34
|
+
return info.fieldNodes[0].selectionSet.selections.some((selection: any) =>
|
|
35
|
+
selection.name.value === fieldName
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isFieldRequestedSafe(info: any, ...path: string[]): boolean {
|
|
40
|
+
if (!info || !info.fieldNodes || info.fieldNodes.length === 0) return false;
|
|
41
|
+
const fieldNode = info.fieldNodes[0];
|
|
42
|
+
if (!fieldNode.selectionSet) return false;
|
|
43
|
+
return isPathSelected(fieldNode.selectionSet, path);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isPathSelected(selectionSet: any, path: string[]): boolean {
|
|
47
|
+
if (path.length === 0) return true;
|
|
48
|
+
const [current, ...rest] = path;
|
|
49
|
+
for (const selection of selectionSet.selections) {
|
|
50
|
+
if (selection.kind === 'Field') {
|
|
51
|
+
if (selection.name.value === current) {
|
|
52
|
+
if (rest.length === 0) return true;
|
|
53
|
+
if (selection.selectionSet) {
|
|
54
|
+
return isPathSelected(selection.selectionSet, rest);
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
} else if (selection.kind === 'InlineFragment' || selection.kind === 'FragmentSpread') {
|
|
59
|
+
// For simplicity, assume fragments are expanded; in practice, they should be resolved
|
|
60
|
+
// Here, we can check if the fragment has the field
|
|
61
|
+
if (selection.selectionSet) {
|
|
62
|
+
if (isPathSelected(selection.selectionSet, path)) return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
package/gql/index.ts
CHANGED
|
@@ -1,12 +1,33 @@
|
|
|
1
|
-
import {createSchema, createYoga} from 'graphql-yoga';
|
|
1
|
+
import {createSchema, createYoga, type Plugin} from 'graphql-yoga';
|
|
2
2
|
import { GraphQLSchema, GraphQLError } from 'graphql';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
3
|
+
import { GraphQLObjectType, GraphQLField, GraphQLOperation, GraphQLScalarType } from './Generator';
|
|
4
|
+
import {GraphQLFieldTypes} from "./types"
|
|
5
|
+
import {logger as MainLogger} from "core/Logger"
|
|
6
|
+
import { isFieldRequested } from './helpers';
|
|
7
|
+
|
|
8
|
+
const logger = MainLogger.child({scope: "GQL"});
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
isValidGraphQLType,
|
|
12
|
+
} from "./helpers";
|
|
13
|
+
import type {
|
|
6
14
|
GraphQLType,
|
|
15
|
+
TypeFromGraphQL,
|
|
16
|
+
ResolverInput
|
|
17
|
+
} from "./helpers";
|
|
18
|
+
export {
|
|
19
|
+
GraphQLObjectType,
|
|
7
20
|
GraphQLField,
|
|
8
21
|
GraphQLOperation,
|
|
9
|
-
|
|
22
|
+
GraphQLFieldTypes,
|
|
23
|
+
isValidGraphQLType,
|
|
24
|
+
GraphQLScalarType,
|
|
25
|
+
isFieldRequested
|
|
26
|
+
}
|
|
27
|
+
export type {
|
|
28
|
+
GraphQLType,
|
|
29
|
+
TypeFromGraphQL,
|
|
30
|
+
ResolverInput
|
|
10
31
|
}
|
|
11
32
|
interface Entity {
|
|
12
33
|
id: string;
|
|
@@ -49,26 +70,47 @@ const staticResolvers = {
|
|
|
49
70
|
}
|
|
50
71
|
};
|
|
51
72
|
|
|
52
|
-
|
|
73
|
+
const maskError = (error: any, message: string): GraphQLError => {
|
|
74
|
+
// Handle authentication errors
|
|
75
|
+
if (error.message === 'Unauthenticated' || error.extensions?.http?.status === 401 || error.extensions?.code === 'UNAUTHENTICATED') {
|
|
76
|
+
return new GraphQLError('Unauthorized', {
|
|
77
|
+
extensions: {
|
|
78
|
+
code: 'UNAUTHORIZED',
|
|
79
|
+
http: { status: 401 }
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Handle JWT authentication errors specifically
|
|
85
|
+
if (error.extensions?.code === 'DOWNSTREAM_SERVICE_ERROR' && error.extensions?.http?.status === 401) {
|
|
86
|
+
return new GraphQLError('Unauthorized', {
|
|
87
|
+
extensions: {
|
|
88
|
+
code: 'UNAUTHORIZED',
|
|
89
|
+
http: { status: 401 }
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (process.env.NODE_ENV === 'production') {
|
|
95
|
+
logger.error("GraphQL Error:", error);
|
|
96
|
+
// Mask sensitive error details in production
|
|
97
|
+
return new GraphQLError('Internal server error', {
|
|
98
|
+
extensions: {
|
|
99
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
// In development, return the original error
|
|
104
|
+
return error instanceof GraphQLError ? error : new GraphQLError(message, { originalError: error });
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export function createYogaInstance(schema?: GraphQLSchema, plugins: Plugin[] = []) {
|
|
53
108
|
if (schema) {
|
|
54
109
|
return createYoga({
|
|
55
110
|
schema,
|
|
56
|
-
|
|
111
|
+
plugins,
|
|
57
112
|
maskedErrors: {
|
|
58
|
-
|
|
59
|
-
// In production, you might want to mask sensitive information
|
|
60
|
-
maskError: (error: any, message: string): GraphQLError => {
|
|
61
|
-
if (process.env.NODE_ENV === 'production') {
|
|
62
|
-
// Mask sensitive error details in production
|
|
63
|
-
return new GraphQLError('Internal server error', {
|
|
64
|
-
extensions: {
|
|
65
|
-
code: 'INTERNAL_SERVER_ERROR',
|
|
66
|
-
},
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
// In development, return the original error
|
|
70
|
-
return error instanceof GraphQLError ? error : new GraphQLError(message, { originalError: error });
|
|
71
|
-
},
|
|
113
|
+
maskError,
|
|
72
114
|
},
|
|
73
115
|
});
|
|
74
116
|
} else {
|
|
@@ -77,17 +119,9 @@ export function createYogaInstance(schema?: GraphQLSchema) {
|
|
|
77
119
|
typeDefs: staticTypeDefs,
|
|
78
120
|
resolvers: staticResolvers,
|
|
79
121
|
}),
|
|
122
|
+
plugins,
|
|
80
123
|
maskedErrors: {
|
|
81
|
-
maskError
|
|
82
|
-
if (process.env.NODE_ENV === 'production') {
|
|
83
|
-
return new GraphQLError('Internal server error', {
|
|
84
|
-
extensions: {
|
|
85
|
-
code: 'INTERNAL_SERVER_ERROR',
|
|
86
|
-
},
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
return error instanceof GraphQLError ? error : new GraphQLError(message, { originalError: error });
|
|
90
|
-
},
|
|
124
|
+
maskError,
|
|
91
125
|
},
|
|
92
126
|
});
|
|
93
127
|
}
|
package/gql/types.ts
CHANGED
package/index.ts
CHANGED
|
@@ -1,22 +1,90 @@
|
|
|
1
|
-
import App from "core/App";
|
|
2
|
-
import ServiceRegistry from "service/ServiceRegistry";
|
|
3
|
-
import BaseService from "service/Service";
|
|
4
|
-
import { Component, CompData, BaseComponent } from "core/Components";
|
|
5
|
-
import { Entity } from "core/Entity";
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
1
|
+
import App from "./core/App";
|
|
2
|
+
import ServiceRegistry from "./service/ServiceRegistry";
|
|
3
|
+
import BaseService from "./service/Service";
|
|
4
|
+
import { Component, CompData, BaseComponent } from "./core/Components";
|
|
5
|
+
import { Entity } from "./core/Entity";
|
|
6
|
+
import ArcheType from "./core/ArcheType";
|
|
7
|
+
import Query from "./core/Query";
|
|
8
|
+
import {logger} from "./core/Logger";
|
|
9
|
+
import { handleGraphQLError, responseError } from "./core/ErrorHandler";
|
|
10
|
+
import { type Plugin } from "graphql-yoga";
|
|
11
|
+
import { BatchLoader } from "core/BatchLoader";
|
|
12
|
+
import { createRequestContextPlugin } from "./core/RequestContext";
|
|
13
|
+
import type { RequestLoaders } from "./core/RequestLoaders";
|
|
14
|
+
// Hook system exports
|
|
15
|
+
import EntityHookManager from "./core/EntityHookManager";
|
|
16
|
+
import {
|
|
17
|
+
EntityHook,
|
|
18
|
+
ComponentHook,
|
|
19
|
+
LifecycleHook,
|
|
20
|
+
ComponentTargetHook,
|
|
21
|
+
registerDecoratedHooks
|
|
22
|
+
} from "./core/decorators/EntityHooks";
|
|
23
|
+
import type {
|
|
24
|
+
EntityHookCallback,
|
|
25
|
+
ComponentHookCallback,
|
|
26
|
+
LifecycleHookCallback,
|
|
27
|
+
HookOptions,
|
|
28
|
+
ComponentTargetConfig
|
|
29
|
+
} from "./core/EntityHookManager";
|
|
30
|
+
import type {
|
|
31
|
+
EntityLifecycleEvent,
|
|
32
|
+
EntityCreatedEvent,
|
|
33
|
+
EntityUpdatedEvent,
|
|
34
|
+
EntityDeletedEvent,
|
|
35
|
+
ComponentLifecycleEvent,
|
|
36
|
+
ComponentAddedEvent,
|
|
37
|
+
ComponentUpdatedEvent,
|
|
38
|
+
ComponentRemovedEvent
|
|
39
|
+
} from "./core/events/EntityLifecycleEvents";
|
|
40
|
+
import { ScheduledTask } from "core/decorators/ScheduledTask";
|
|
41
|
+
import { ScheduleInterval } from "types/scheduler.types";
|
|
42
|
+
|
|
9
43
|
export {
|
|
10
|
-
App,
|
|
44
|
+
App,
|
|
45
|
+
ArcheType,
|
|
11
46
|
ServiceRegistry,
|
|
12
47
|
BaseService,
|
|
13
48
|
BaseComponent,
|
|
14
49
|
Component,
|
|
15
50
|
CompData,
|
|
16
51
|
Entity,
|
|
52
|
+
BatchLoader,
|
|
53
|
+
|
|
17
54
|
Query,
|
|
55
|
+
|
|
56
|
+
// Scheduler exports
|
|
57
|
+
ScheduleInterval,
|
|
58
|
+
ScheduledTask,
|
|
59
|
+
|
|
18
60
|
logger,
|
|
19
61
|
|
|
62
|
+
type Plugin,
|
|
63
|
+
|
|
20
64
|
responseError,
|
|
21
|
-
handleGraphQLError
|
|
22
|
-
|
|
65
|
+
handleGraphQLError,
|
|
66
|
+
|
|
67
|
+
createRequestContextPlugin,
|
|
68
|
+
type RequestLoaders,
|
|
69
|
+
|
|
70
|
+
// Hook system exports
|
|
71
|
+
EntityHookManager,
|
|
72
|
+
EntityHook,
|
|
73
|
+
ComponentHook,
|
|
74
|
+
LifecycleHook,
|
|
75
|
+
ComponentTargetHook,
|
|
76
|
+
registerDecoratedHooks,
|
|
77
|
+
type EntityHookCallback,
|
|
78
|
+
type ComponentHookCallback,
|
|
79
|
+
type LifecycleHookCallback,
|
|
80
|
+
type HookOptions,
|
|
81
|
+
type ComponentTargetConfig,
|
|
82
|
+
type EntityLifecycleEvent,
|
|
83
|
+
type EntityCreatedEvent,
|
|
84
|
+
type EntityUpdatedEvent,
|
|
85
|
+
type EntityDeletedEvent,
|
|
86
|
+
type ComponentLifecycleEvent,
|
|
87
|
+
type ComponentAddedEvent,
|
|
88
|
+
type ComponentUpdatedEvent,
|
|
89
|
+
type ComponentRemovedEvent
|
|
90
|
+
};
|
package/package.json
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunsane",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"author": {
|
|
5
5
|
"name": "yaaruu"
|
|
6
6
|
},
|
|
7
|
+
"keywords": [
|
|
8
|
+
"bun",
|
|
9
|
+
"framework",
|
|
10
|
+
"entity-component-system",
|
|
11
|
+
"ecs",
|
|
12
|
+
"graphql",
|
|
13
|
+
"typescript"
|
|
14
|
+
],
|
|
7
15
|
"module": "index.ts",
|
|
8
16
|
"type": "module",
|
|
9
17
|
"devDependencies": {
|
|
@@ -13,16 +21,17 @@
|
|
|
13
21
|
"typescript": "^5"
|
|
14
22
|
},
|
|
15
23
|
"dependencies": {
|
|
16
|
-
"
|
|
17
|
-
"graphql
|
|
18
|
-
"
|
|
19
|
-
"pino
|
|
20
|
-
"
|
|
21
|
-
"
|
|
24
|
+
"dataloader": "2.2.2",
|
|
25
|
+
"graphql": "16.11.0",
|
|
26
|
+
"graphql-yoga": "5.15.1",
|
|
27
|
+
"pino": "9.9.0",
|
|
28
|
+
"pino-pretty": "13.1.1",
|
|
29
|
+
"reflect-metadata": "0.2.2",
|
|
30
|
+
"zod": "4.1.5"
|
|
22
31
|
},
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "https://github.com/yaaruu/bunsane.git"
|
|
26
35
|
},
|
|
27
36
|
"license": "MIT"
|
|
28
37
|
}
|
package/rest/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD';
|
|
2
|
+
export type HTTPEndpointOptions = {
|
|
3
|
+
method: HTTPMethod;
|
|
4
|
+
path: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function httpEndpoint(options: HTTPEndpointOptions) {
|
|
8
|
+
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
|
9
|
+
if (!target.constructor.httpEndpoints) {
|
|
10
|
+
target.constructor.httpEndpoints = [];
|
|
11
|
+
}
|
|
12
|
+
target.constructor.httpEndpoints.push({
|
|
13
|
+
method: options.method,
|
|
14
|
+
path: options.path,
|
|
15
|
+
handler: descriptor.value
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RestController {
|
|
21
|
+
httpEndpoints: Array<{ method: HTTPMethod; path: string; handler: Function }>;
|
|
22
|
+
}
|
package/service/Service.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { GraphQLSchema } from "graphql";
|
|
|
6
6
|
class ServiceRegistry {
|
|
7
7
|
static #instance: ServiceRegistry;
|
|
8
8
|
|
|
9
|
-
private
|
|
9
|
+
private services: Map<string, BaseService> = new Map();
|
|
10
10
|
private schema: GraphQLSchema | null = null;
|
|
11
11
|
|
|
12
12
|
|
|
@@ -18,8 +18,8 @@ class ServiceRegistry {
|
|
|
18
18
|
ApplicationLifecycle.addPhaseListener((event) => {
|
|
19
19
|
switch(event.detail) {
|
|
20
20
|
case ApplicationPhase.SYSTEM_REGISTERING: {
|
|
21
|
-
const
|
|
22
|
-
const { schema } = generateGraphQLSchema(
|
|
21
|
+
const servicesArray = Array.from(this.services.values());
|
|
22
|
+
const { schema } = generateGraphQLSchema(servicesArray);
|
|
23
23
|
this.schema = schema;
|
|
24
24
|
ApplicationLifecycle.setPhase(ApplicationPhase.SYSTEM_READY);
|
|
25
25
|
break;
|
|
@@ -37,12 +37,16 @@ class ServiceRegistry {
|
|
|
37
37
|
return ServiceRegistry.#instance;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
public registerService(
|
|
41
|
-
if(!this.
|
|
42
|
-
this.
|
|
40
|
+
public registerService(service: BaseService) {
|
|
41
|
+
if(!this.services.has(service.constructor.name)) {
|
|
42
|
+
this.services.set(service.constructor.name, service);
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
public getServices(): BaseService[] {
|
|
47
|
+
return Array.from(this.services.values());
|
|
48
|
+
}
|
|
49
|
+
|
|
46
50
|
public getSchema(): GraphQLSchema | null {
|
|
47
51
|
return this.schema;
|
|
48
52
|
}
|
package/service/index.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import BaseService from "./Service";
|
|
2
2
|
import { ServiceRegistry } from "index";
|
|
3
|
+
import { httpEndpoint } from "../rest";
|
|
4
|
+
|
|
3
5
|
export {
|
|
4
6
|
BaseService,
|
|
5
7
|
ServiceRegistry
|
|
6
|
-
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Shorthand decorators for HTTP methods
|
|
11
|
+
export const Get = (path: string) => httpEndpoint({ method: 'GET', path });
|
|
12
|
+
export const Post = (path: string) => httpEndpoint({ method: 'POST', path });
|
|
13
|
+
export const Put = (path: string) => httpEndpoint({ method: 'PUT', path });
|
|
14
|
+
export const Delete = (path: string) => httpEndpoint({ method: 'DELETE', path });
|
|
15
|
+
export const Patch = (path: string) => httpEndpoint({ method: 'PATCH', path });
|
|
16
|
+
export const Options = (path: string) => httpEndpoint({ method: 'OPTIONS', path });
|
|
17
|
+
export const Head = (path: string) => httpEndpoint({ method: 'HEAD', path });
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {describe, test, expect, beforeAll, beforeEach} from "bun:test"
|
|
2
|
+
import App from "core/App"
|
|
3
|
+
import { BaseComponent, CompData, Component } from "core/Components";
|
|
4
|
+
import { Entity } from "core/Entity";
|
|
5
|
+
import db from "database";
|
|
6
|
+
|
|
7
|
+
let app;
|
|
8
|
+
beforeAll(async () => {
|
|
9
|
+
app = new App();
|
|
10
|
+
await app.waitForAppReady();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
await db`TRUNCATE TABLE entities CASCADE;`;
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
@Component
|
|
18
|
+
class TestComponent extends BaseComponent {
|
|
19
|
+
@CompData()
|
|
20
|
+
value: string = "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@Component
|
|
24
|
+
class AnotherComponent extends BaseComponent {
|
|
25
|
+
@CompData()
|
|
26
|
+
numberValue: number = 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Component
|
|
30
|
+
class YetAnotherComponent extends BaseComponent {
|
|
31
|
+
@CompData()
|
|
32
|
+
boolValue: boolean = false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Component
|
|
36
|
+
class MassiveComponent extends BaseComponent {
|
|
37
|
+
@CompData()
|
|
38
|
+
value: string = "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('Insert Entity Tests', () => {
|
|
42
|
+
test('Creating 10000 entities', async () => {
|
|
43
|
+
const entities = [];
|
|
44
|
+
for(let i = 0; i < 10000; i++) {
|
|
45
|
+
const entity = Entity.Create()
|
|
46
|
+
.add(TestComponent, {value: `Test ${i}`})
|
|
47
|
+
.add(AnotherComponent, {numberValue: i})
|
|
48
|
+
.add(YetAnotherComponent, {boolValue: i % 2 === 0})
|
|
49
|
+
.add(MassiveComponent, {value: "x".repeat(1000)});
|
|
50
|
+
entities.push(entity);
|
|
51
|
+
}
|
|
52
|
+
const start = performance.now();
|
|
53
|
+
await Promise.all(entities.map(entity => entity.save()));
|
|
54
|
+
const end = performance.now();
|
|
55
|
+
console.log(`Time taken to create 10000 entities: ${end - start}ms`);
|
|
56
|
+
const countResult :any = await db<{count: number}>`SELECT COUNT(*)::int FROM entities;`;
|
|
57
|
+
expect(countResult[0].count).toBe(10000);
|
|
58
|
+
});
|
|
59
|
+
});
|