bunsane 0.1.0 → 0.1.1
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 +119 -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 +159 -12
- 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 +453 -85
- package/core/RequestContext.ts +24 -0
- package/core/RequestLoaders.ts +65 -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 +1 -1
- 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/examples/hooks/README.md +228 -0
- package/examples/hooks/audit-logger.ts +495 -0
- package/gql/Generator.ts +56 -34
- package/gql/decorators/Upload.ts +176 -0
- package/gql/helpers.ts +67 -0
- package/gql/index.ts +55 -31
- package/gql/types.ts +1 -1
- package/index.ts +79 -11
- package/package.json +5 -4
- 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 +205 -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/validate-docs.sh +90 -0
- package/core/Events.ts +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
name: Deploy Documentation to GitHub Pages
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main ]
|
|
6
|
+
paths:
|
|
7
|
+
- 'docs/**'
|
|
8
|
+
- '.github/workflows/deploy-docs.yml'
|
|
9
|
+
pull_request:
|
|
10
|
+
branches: [ main ]
|
|
11
|
+
paths:
|
|
12
|
+
- 'docs/**'
|
|
13
|
+
- '.github/workflows/deploy-docs.yml'
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
build-and-deploy:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
|
|
19
|
+
steps:
|
|
20
|
+
- name: Checkout
|
|
21
|
+
uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- name: Setup Node.js
|
|
24
|
+
uses: actions/setup-node@v4
|
|
25
|
+
with:
|
|
26
|
+
node-version: '18'
|
|
27
|
+
|
|
28
|
+
- name: Install dependencies
|
|
29
|
+
run: |
|
|
30
|
+
cd docs
|
|
31
|
+
npm install -g docsify-cli@latest
|
|
32
|
+
|
|
33
|
+
- name: Build documentation
|
|
34
|
+
run: |
|
|
35
|
+
cd docs
|
|
36
|
+
# Generate any dynamic content if needed
|
|
37
|
+
echo "Building documentation..."
|
|
38
|
+
|
|
39
|
+
- name: Deploy to GitHub Pages
|
|
40
|
+
if: github.ref == 'refs/heads/main'
|
|
41
|
+
uses: peaceiris/actions-gh-pages@v3
|
|
42
|
+
with:
|
|
43
|
+
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
44
|
+
publish_dir: ./docs
|
|
45
|
+
|
|
46
|
+
validate-links:
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
steps:
|
|
49
|
+
- name: Checkout
|
|
50
|
+
uses: actions/checkout@v4
|
|
51
|
+
|
|
52
|
+
- name: Link Checker
|
|
53
|
+
uses: lycheeverse/lychee-action@v1.8.0
|
|
54
|
+
with:
|
|
55
|
+
args: --config docs/.lychee.toml docs/
|
|
56
|
+
env:
|
|
57
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
package/LICENSE.md
CHANGED
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<img src="
|
|
3
|
+
<img src="https://raw.githubusercontent.com/yaaruu/bunsane/refs/heads/main/BunSane.jpg" alt="BunSane" width="520" />
|
|
4
4
|
|
|
5
5
|
# BunSane — Batteries‑included TypeScript API framework for Bun
|
|
6
6
|
|
|
@@ -20,35 +20,9 @@
|
|
|
20
20
|
- Pino logging, pretty mode in development
|
|
21
21
|
- Zod-friendly GraphQL error helper
|
|
22
22
|
|
|
23
|
-
## Install
|
|
24
23
|
|
|
25
|
-
|
|
24
|
+
Full documentation visit: [Documentation](https://yaaruu.github.io/bunsane/#/)
|
|
26
25
|
|
|
27
|
-
```cmd
|
|
28
|
-
bun install @yaaruu/bunsane
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
Ensure your tsconfig enables decorators in your app:
|
|
32
|
-
|
|
33
|
-
```json
|
|
34
|
-
{
|
|
35
|
-
"compilerOptions": {
|
|
36
|
-
"experimentalDecorators": true,
|
|
37
|
-
"emitDecoratorMetadata": true
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
Full documentation visit: [Documentation](https://example.com)
|
|
43
|
-
|
|
44
|
-
## Core concepts
|
|
45
|
-
|
|
46
|
-
### ECS ( Entity Component Services )
|
|
47
|
-
TODO
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
## LICENSE
|
|
51
|
-
MIT
|
|
52
26
|
|
|
53
27
|
---
|
|
54
28
|
|
package/TODO.md
CHANGED
|
@@ -1 +1,8 @@
|
|
|
1
|
-
//
|
|
1
|
+
// CORE FEATURE
|
|
2
|
+
TODO: Custom Component Table with Direct Column instead of jsonb
|
|
3
|
+
TODO: Add GraphQL type registration for Interface, Enum, etc
|
|
4
|
+
|
|
5
|
+
// QOL FEATURE
|
|
6
|
+
TODO: Filesystem
|
|
7
|
+
TODO: Field Constraint
|
|
8
|
+
TODO: Add OpenAPI Swagger Spec
|
package/bun.lock
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"": {
|
|
5
5
|
"name": "bunsane",
|
|
6
6
|
"dependencies": {
|
|
7
|
+
"dataloader": "^2.2.2",
|
|
7
8
|
"graphql": "^16.11.0",
|
|
8
9
|
"graphql-yoga": "^5.15.1",
|
|
9
10
|
"pino": "^9.9.0",
|
|
@@ -74,6 +75,8 @@
|
|
|
74
75
|
|
|
75
76
|
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
|
76
77
|
|
|
78
|
+
"dataloader": ["dataloader@2.2.3", "", {}, "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA=="],
|
|
79
|
+
|
|
77
80
|
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
|
|
78
81
|
|
|
79
82
|
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { UploadConfiguration } from "../types/upload.types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default Upload Configuration
|
|
5
|
+
* Contains sensible defaults for the upload system
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_UPLOAD_CONFIG: UploadConfiguration = {
|
|
8
|
+
maxFileSize: 10 * 1024 * 1024, // 10MB
|
|
9
|
+
allowedMimeTypes: [
|
|
10
|
+
// Images
|
|
11
|
+
"image/jpeg",
|
|
12
|
+
"image/png",
|
|
13
|
+
"image/gif",
|
|
14
|
+
"image/webp",
|
|
15
|
+
"image/svg+xml",
|
|
16
|
+
// Documents
|
|
17
|
+
"application/pdf",
|
|
18
|
+
"text/plain",
|
|
19
|
+
"application/msword",
|
|
20
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
21
|
+
],
|
|
22
|
+
allowedExtensions: [
|
|
23
|
+
// Images
|
|
24
|
+
".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg",
|
|
25
|
+
// Documents
|
|
26
|
+
".pdf", ".txt", ".doc", ".docx"
|
|
27
|
+
],
|
|
28
|
+
validateFileSignature: true,
|
|
29
|
+
sanitizeFileName: true,
|
|
30
|
+
preserveOriginalName: false,
|
|
31
|
+
generateThumbnails: false,
|
|
32
|
+
uploadPath: "uploads",
|
|
33
|
+
namingStrategy: "uuid",
|
|
34
|
+
imageProcessing: {
|
|
35
|
+
generateThumbnails: false,
|
|
36
|
+
thumbnailSizes: [
|
|
37
|
+
{ width: 150, height: 150, suffix: "_thumb" },
|
|
38
|
+
{ width: 300, height: 300, suffix: "_medium" },
|
|
39
|
+
{ width: 800, height: 600, suffix: "_large" }
|
|
40
|
+
],
|
|
41
|
+
compress: true,
|
|
42
|
+
quality: 85,
|
|
43
|
+
maxDimensions: { width: 2048, height: 2048 }
|
|
44
|
+
},
|
|
45
|
+
validation: {
|
|
46
|
+
scanForMalware: false,
|
|
47
|
+
strictMimeType: true,
|
|
48
|
+
customValidators: []
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Image-specific upload configuration
|
|
54
|
+
*/
|
|
55
|
+
export const IMAGE_UPLOAD_CONFIG: Partial<UploadConfiguration> = {
|
|
56
|
+
maxFileSize: 5 * 1024 * 1024, // 5MB
|
|
57
|
+
allowedMimeTypes: [
|
|
58
|
+
"image/jpeg",
|
|
59
|
+
"image/png",
|
|
60
|
+
"image/gif",
|
|
61
|
+
"image/webp"
|
|
62
|
+
],
|
|
63
|
+
allowedExtensions: [".jpg", ".jpeg", ".png", ".gif", ".webp"],
|
|
64
|
+
generateThumbnails: true,
|
|
65
|
+
imageProcessing: {
|
|
66
|
+
generateThumbnails: true,
|
|
67
|
+
thumbnailSizes: [
|
|
68
|
+
{ width: 150, height: 150, suffix: "_thumb" },
|
|
69
|
+
{ width: 300, height: 300, suffix: "_medium" }
|
|
70
|
+
],
|
|
71
|
+
compress: true,
|
|
72
|
+
quality: 85,
|
|
73
|
+
maxDimensions: { width: 1920, height: 1080 }
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Document upload configuration
|
|
79
|
+
*/
|
|
80
|
+
export const DOCUMENT_UPLOAD_CONFIG: Partial<UploadConfiguration> = {
|
|
81
|
+
maxFileSize: 25 * 1024 * 1024, // 25MB
|
|
82
|
+
allowedMimeTypes: [
|
|
83
|
+
"application/pdf",
|
|
84
|
+
"text/plain",
|
|
85
|
+
"application/msword",
|
|
86
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
87
|
+
"application/vnd.ms-excel",
|
|
88
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
89
|
+
],
|
|
90
|
+
allowedExtensions: [".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx"],
|
|
91
|
+
validateFileSignature: true,
|
|
92
|
+
generateThumbnails: false,
|
|
93
|
+
validation: {
|
|
94
|
+
scanForMalware: true,
|
|
95
|
+
strictMimeType: true
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Avatar/profile picture configuration
|
|
101
|
+
*/
|
|
102
|
+
export const AVATAR_UPLOAD_CONFIG: Partial<UploadConfiguration> = {
|
|
103
|
+
maxFileSize: 2 * 1024 * 1024, // 2MB
|
|
104
|
+
allowedMimeTypes: ["image/jpeg", "image/png", "image/webp"],
|
|
105
|
+
allowedExtensions: [".jpg", ".jpeg", ".png", ".webp"],
|
|
106
|
+
generateThumbnails: true,
|
|
107
|
+
imageProcessing: {
|
|
108
|
+
generateThumbnails: true,
|
|
109
|
+
thumbnailSizes: [
|
|
110
|
+
{ width: 50, height: 50, suffix: "_small" },
|
|
111
|
+
{ width: 150, height: 150, suffix: "_medium" },
|
|
112
|
+
{ width: 300, height: 300, suffix: "_large" }
|
|
113
|
+
],
|
|
114
|
+
compress: true,
|
|
115
|
+
quality: 90,
|
|
116
|
+
maxDimensions: { width: 800, height: 800 }
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Strict security configuration for public uploads
|
|
122
|
+
*/
|
|
123
|
+
export const SECURE_UPLOAD_CONFIG: Partial<UploadConfiguration> = {
|
|
124
|
+
maxFileSize: 1 * 1024 * 1024, // 1MB
|
|
125
|
+
allowedMimeTypes: ["image/jpeg", "image/png"],
|
|
126
|
+
allowedExtensions: [".jpg", ".jpeg", ".png"],
|
|
127
|
+
validateFileSignature: true,
|
|
128
|
+
sanitizeFileName: true,
|
|
129
|
+
preserveOriginalName: false,
|
|
130
|
+
validation: {
|
|
131
|
+
scanForMalware: true,
|
|
132
|
+
strictMimeType: true,
|
|
133
|
+
customValidators: []
|
|
134
|
+
}
|
|
135
|
+
};
|
package/core/App.ts
CHANGED
|
@@ -4,9 +4,18 @@ import ComponentRegistry from "core/ComponentRegistry";
|
|
|
4
4
|
import { logger } from "core/Logger";
|
|
5
5
|
import { createYogaInstance } from "gql";
|
|
6
6
|
import ServiceRegistry from "service/ServiceRegistry";
|
|
7
|
+
import type { Plugin } from "graphql-yoga";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
import { registerDecoratedHooks } from "core/decorators/EntityHooks";
|
|
10
|
+
import { SchedulerManager } from "core/SchedulerManager";
|
|
11
|
+
import { registerScheduledTasks } from "core/decorators/ScheduledTask";
|
|
7
12
|
|
|
8
13
|
export default class App {
|
|
9
14
|
private yoga: any;
|
|
15
|
+
private yogaPlugins: Plugin[] = [];
|
|
16
|
+
private restEndpoints: Array<{ method: string; path: string; handler: Function; service: any }> = [];
|
|
17
|
+
private restEndpointMap: Map<string, { method: string; path: string; handler: Function; service: any }> = new Map();
|
|
18
|
+
private staticAssets: Map<string, string> = new Map();
|
|
10
19
|
|
|
11
20
|
constructor() {
|
|
12
21
|
this.init();
|
|
@@ -32,6 +41,18 @@ export default class App {
|
|
|
32
41
|
break;
|
|
33
42
|
}
|
|
34
43
|
case ApplicationPhase.COMPONENTS_READY: {
|
|
44
|
+
// Automatically register decorated hooks for all services
|
|
45
|
+
const services = ServiceRegistry.getServices();
|
|
46
|
+
for (const service of services) {
|
|
47
|
+
try {
|
|
48
|
+
registerDecoratedHooks(service);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
logger.warn(`Failed to register hooks for service ${service.constructor.name}`);
|
|
51
|
+
logger.warn(error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
logger.info(`Registered hooks for ${services.length} services`);
|
|
55
|
+
|
|
35
56
|
ApplicationLifecycle.setPhase(ApplicationPhase.SYSTEM_REGISTERING);
|
|
36
57
|
break;
|
|
37
58
|
}
|
|
@@ -39,10 +60,45 @@ export default class App {
|
|
|
39
60
|
try {
|
|
40
61
|
const schema = ServiceRegistry.getSchema();
|
|
41
62
|
if (schema) {
|
|
42
|
-
this.yoga = createYogaInstance(schema);
|
|
63
|
+
this.yoga = createYogaInstance(schema, this.yogaPlugins);
|
|
43
64
|
} else {
|
|
44
|
-
this.yoga = createYogaInstance();
|
|
65
|
+
this.yoga = createYogaInstance(undefined, this.yogaPlugins);
|
|
45
66
|
}
|
|
67
|
+
|
|
68
|
+
// Get all services for processing
|
|
69
|
+
const services = ServiceRegistry.getServices();
|
|
70
|
+
|
|
71
|
+
// Initialize Scheduler
|
|
72
|
+
const scheduler = SchedulerManager.getInstance();
|
|
73
|
+
|
|
74
|
+
// Register scheduled tasks for all services
|
|
75
|
+
for (const service of services) {
|
|
76
|
+
try {
|
|
77
|
+
registerScheduledTasks(service);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
logger.warn(`Failed to register scheduled tasks for service ${service.constructor.name}`);
|
|
80
|
+
logger.warn(error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
logger.info(`Registered scheduled tasks for ${services.length} services`);
|
|
84
|
+
|
|
85
|
+
// Collect REST endpoints from all services
|
|
86
|
+
for (const service of services) {
|
|
87
|
+
const endpoints = (service.constructor as any).httpEndpoints;
|
|
88
|
+
if (endpoints) {
|
|
89
|
+
for (const endpoint of endpoints) {
|
|
90
|
+
const endpointInfo = {
|
|
91
|
+
method: endpoint.method,
|
|
92
|
+
path: endpoint.path,
|
|
93
|
+
handler: endpoint.handler.bind(service),
|
|
94
|
+
service: service
|
|
95
|
+
};
|
|
96
|
+
this.restEndpoints.push(endpointInfo);
|
|
97
|
+
this.restEndpointMap.set(`${endpoint.method}:${endpoint.path}`, endpointInfo);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
46
102
|
ApplicationLifecycle.setPhase(ApplicationPhase.APPLICATION_READY);
|
|
47
103
|
} catch (error) {
|
|
48
104
|
logger.error("Error during SYSTEM_READY phase:");
|
|
@@ -71,11 +127,70 @@ export default class App {
|
|
|
71
127
|
});
|
|
72
128
|
}
|
|
73
129
|
|
|
130
|
+
public addYogaPlugin(plugin: Plugin) {
|
|
131
|
+
this.yogaPlugins.push(plugin);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
public addStaticAssets(route: string, folder: string) {
|
|
135
|
+
// Resolve the folder path relative to the current working directory
|
|
136
|
+
const resolvedFolder = path.resolve(folder);
|
|
137
|
+
this.staticAssets.set(route, resolvedFolder);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async handleRequest(req: Request): Promise<Response> {
|
|
141
|
+
const url = new URL(req.url);
|
|
142
|
+
const method = req.method;
|
|
143
|
+
|
|
144
|
+
// Check for static assets
|
|
145
|
+
for (const [route, folder] of this.staticAssets) {
|
|
146
|
+
if (url.pathname.startsWith(route)) {
|
|
147
|
+
const relativePath = url.pathname.slice(route.length);
|
|
148
|
+
const filePath = path.join(folder, relativePath);
|
|
149
|
+
try {
|
|
150
|
+
const file = Bun.file(filePath);
|
|
151
|
+
if (await file.exists()) {
|
|
152
|
+
return new Response(file);
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
logger.error(`Error serving static file ${filePath}:`, error as any);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Lookup REST endpoint using map for O(1) performance
|
|
161
|
+
const endpointKey = `${method}:${url.pathname}`;
|
|
162
|
+
const endpoint = this.restEndpointMap.get(endpointKey);
|
|
163
|
+
if (endpoint) {
|
|
164
|
+
try {
|
|
165
|
+
const result = await endpoint.handler(req);
|
|
166
|
+
if (result instanceof Response) {
|
|
167
|
+
return result;
|
|
168
|
+
} else {
|
|
169
|
+
return new Response(JSON.stringify(result), {
|
|
170
|
+
headers: { 'Content-Type': 'application/json' }
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
} catch (error) {
|
|
174
|
+
logger.error(`Error in REST endpoint ${method} ${endpoint.path}`, error as any);
|
|
175
|
+
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
|
176
|
+
status: 500,
|
|
177
|
+
headers: { 'Content-Type': 'application/json' }
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (this.yoga) {
|
|
183
|
+
return this.yoga(req);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return new Response('Not Found', { status: 404 });
|
|
187
|
+
}
|
|
188
|
+
|
|
74
189
|
async start() {
|
|
75
190
|
logger.info("Application Started");
|
|
76
191
|
const server = Bun.serve({
|
|
77
|
-
fetch: this.
|
|
192
|
+
fetch: this.handleRequest.bind(this),
|
|
78
193
|
});
|
|
79
|
-
logger.info(`Server is running on ${new URL(this.yoga
|
|
194
|
+
logger.info(`Server is running on ${new URL(this.yoga?.graphqlEndpoint || '/graphql', `http://${server.hostname}:${server.port}`)}`)
|
|
80
195
|
}
|
|
81
196
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { BaseComponent, ComponentDataType } from "./Components";
|
|
2
|
+
import { Entity } from "./Entity";
|
|
3
|
+
|
|
4
|
+
function compNameToFieldName(compName: string): string {
|
|
5
|
+
return compName.charAt(0).toLowerCase() + compName.slice(1).replace(/Component$/, '');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ArcheType provides a layer of abstraction for creating entities with predefined sets of components.
|
|
10
|
+
* This makes entity creation more elegant and reduces code repetition.
|
|
11
|
+
*
|
|
12
|
+
* Example usage:
|
|
13
|
+
* ```typescript
|
|
14
|
+
* const UserArcheType = new ArcheType([NameComponent, EmailComponent, PasswordComponent]);
|
|
15
|
+
*
|
|
16
|
+
*
|
|
17
|
+
* // FROM Request or other source
|
|
18
|
+
* const userInput = { name: "John Doe", email: "john@example.com", password: "securepassword" };
|
|
19
|
+
* const entity = UserArcheType.fill(userInput).createEntity();
|
|
20
|
+
* await entity.save();
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ArcheType {
|
|
26
|
+
protected components: Set<{ ctor: new (...args: any[]) => BaseComponent, data: any }> = new Set();
|
|
27
|
+
protected componentMap: Record<string, typeof BaseComponent> = {};
|
|
28
|
+
|
|
29
|
+
constructor(components: Array<new (...args: any[]) => BaseComponent>) {
|
|
30
|
+
for (const ctor of components) {
|
|
31
|
+
this.componentMap[compNameToFieldName(ctor.name)] = ctor;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
private addComponent<T extends BaseComponent>(ctor: new (...args: any[]) => T, data: ComponentDataType<T>) {
|
|
37
|
+
this.componentMap[compNameToFieldName(ctor.name)] = ctor;
|
|
38
|
+
this.components.add({ ctor, data });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
// TODO: Can we make this type-safe?
|
|
43
|
+
public fill(input: object, strict: boolean = false): this {
|
|
44
|
+
for (const [key, value] of Object.entries(input)) {
|
|
45
|
+
if (value !== undefined) {
|
|
46
|
+
const compCtor = this.componentMap[key];
|
|
47
|
+
if (compCtor) {
|
|
48
|
+
this.addComponent(compCtor, { value });
|
|
49
|
+
} else {
|
|
50
|
+
if (strict) {
|
|
51
|
+
throw new Error(`Component for field '${key}' not found in archetype.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const [field, ctor] of Object.entries(this.componentMap)) {
|
|
57
|
+
const alreadyAdded = Array.from(this.components).some(c => c.ctor === ctor);
|
|
58
|
+
if (!alreadyAdded) {
|
|
59
|
+
this.addComponent(ctor, {} as any);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async updateEntity<T>(entity: Entity, updates: Partial<T>) {
|
|
67
|
+
for (const key of Object.keys(updates)) {
|
|
68
|
+
if(key === 'id' || key === '_id') continue;
|
|
69
|
+
const value = updates[key as keyof T];
|
|
70
|
+
if (value !== undefined) {
|
|
71
|
+
const compCtor = this.componentMap[key];
|
|
72
|
+
if (compCtor) {
|
|
73
|
+
await entity.set(compCtor, { value });
|
|
74
|
+
} else {
|
|
75
|
+
throw new Error(`Component for field '${key}' not found in archetype.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Creates a new entity with all the predefined components from this archetype.
|
|
83
|
+
* @returns A new Entity instance with all archetype components added
|
|
84
|
+
*/
|
|
85
|
+
public createEntity(): Entity {
|
|
86
|
+
const entity = Entity.Create();
|
|
87
|
+
for (const { ctor, data } of this.components) {
|
|
88
|
+
entity.add(ctor, data);
|
|
89
|
+
}
|
|
90
|
+
return entity;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Creates a new entity and immediately saves it to the database.
|
|
95
|
+
* @returns A promise that resolves to the saved Entity
|
|
96
|
+
*/
|
|
97
|
+
public async createAndSaveEntity(): Promise<Entity> {
|
|
98
|
+
const entity = this.createEntity();
|
|
99
|
+
await entity.save();
|
|
100
|
+
return entity;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Unwraps an entity into a plain object containing the component data.
|
|
105
|
+
* @param entity The entity to unwrap
|
|
106
|
+
* @param exclude An optional array of field names to exclude from the result (e.g., sensitive data like passwords)
|
|
107
|
+
* @returns A promise that resolves to an object with component data
|
|
108
|
+
*/
|
|
109
|
+
public async Unwrap(entity: Entity, exclude: string[] = []): Promise<Record<string, any>> {
|
|
110
|
+
const result: any = { id: entity.id };
|
|
111
|
+
for (const [field, ctor] of Object.entries(this.componentMap)) {
|
|
112
|
+
if (exclude.includes(field)) continue;
|
|
113
|
+
const comp = await entity.get(ctor as any);
|
|
114
|
+
if (comp) {
|
|
115
|
+
result[field] = (comp as any).value;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export default ArcheType;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Entity } from "core/Entity";
|
|
2
|
+
import { BaseComponent } from "core/Components";
|
|
3
|
+
import { timed } from "./Decorators";
|
|
4
|
+
import db from "../database";
|
|
5
|
+
import { sql } from "bun";
|
|
6
|
+
|
|
7
|
+
// Phase 2A: Memory Pooling for Entity Objects
|
|
8
|
+
class EntityPool {
|
|
9
|
+
private static instance: EntityPool;
|
|
10
|
+
private pool: Map<string, Entity[]> = new Map();
|
|
11
|
+
private maxPoolSize = 1000;
|
|
12
|
+
|
|
13
|
+
static getInstance(): EntityPool {
|
|
14
|
+
if (!EntityPool.instance) {
|
|
15
|
+
EntityPool.instance = new EntityPool();
|
|
16
|
+
}
|
|
17
|
+
return EntityPool.instance;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get(entityId: string): Entity | null {
|
|
21
|
+
const entities = this.pool.get(entityId);
|
|
22
|
+
if (entities && entities.length > 0) {
|
|
23
|
+
return entities.pop()!;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
put(entity: Entity): void {
|
|
29
|
+
const entityId = entity.id;
|
|
30
|
+
let entities = this.pool.get(entityId);
|
|
31
|
+
if (!entities) {
|
|
32
|
+
entities = [];
|
|
33
|
+
this.pool.set(entityId, entities);
|
|
34
|
+
}
|
|
35
|
+
if (entities.length < this.maxPoolSize) {
|
|
36
|
+
entities.push(entity);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
clear(): void {
|
|
41
|
+
this.pool.clear();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class BatchLoader {
|
|
46
|
+
private static entityPool = EntityPool.getInstance();
|
|
47
|
+
|
|
48
|
+
@timed("BatchLoader.loadRelatedEntities")
|
|
49
|
+
static async loadRelatedEntities<C extends BaseComponent & { value: string }>(
|
|
50
|
+
entities: Entity[],
|
|
51
|
+
component: new () => C,
|
|
52
|
+
loader: (ids: string[]) => Promise<Entity[]>
|
|
53
|
+
): Promise<Map<string, Entity>> {
|
|
54
|
+
const ids: string[] = [];
|
|
55
|
+
for (const entity of entities) {
|
|
56
|
+
const data = await entity.get(component) as any;
|
|
57
|
+
if (data?.value) {
|
|
58
|
+
ids.push(data.value);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const uniqueIds = [...new Set(ids)];
|
|
62
|
+
const relatedEntities = await loader(uniqueIds);
|
|
63
|
+
const map = new Map<string, Entity>();
|
|
64
|
+
for (const related of relatedEntities) {
|
|
65
|
+
map.set(related.id, related);
|
|
66
|
+
}
|
|
67
|
+
return map;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@timed("BatchLoader.loadRelatedEntitiesBatched")
|
|
71
|
+
static async loadRelatedEntitiesBatched<C extends BaseComponent>(
|
|
72
|
+
entities: Entity[],
|
|
73
|
+
component: new () => C,
|
|
74
|
+
loader: (ids: string[]) => Promise<Entity[]>
|
|
75
|
+
): Promise<Map<string, Entity>> {
|
|
76
|
+
if (entities.length === 0) return new Map();
|
|
77
|
+
|
|
78
|
+
const comp = new component();
|
|
79
|
+
const typeId = comp.getTypeID();
|
|
80
|
+
const parentIds = entities.map(e => e.id);
|
|
81
|
+
|
|
82
|
+
const rows = await db`
|
|
83
|
+
SELECT c.entity_id, (c.data->>'value') AS related_id
|
|
84
|
+
FROM components c
|
|
85
|
+
WHERE c.entity_id IN ${sql(parentIds)}
|
|
86
|
+
AND c.type_id = ${typeId}
|
|
87
|
+
AND c.deleted_at IS NULL
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
const uniqueIds = [...new Set(rows.map((r: any) => r.related_id).filter(Boolean))] as string[];
|
|
91
|
+
if (uniqueIds.length === 0) return new Map();
|
|
92
|
+
|
|
93
|
+
const relatedEntities = await loader(uniqueIds);
|
|
94
|
+
const map = new Map<string, Entity>();
|
|
95
|
+
for (const related of relatedEntities) {
|
|
96
|
+
map.set(related.id, related);
|
|
97
|
+
}
|
|
98
|
+
return map;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { generateTypeId, type BaseComponent } from "./Components";
|
|
2
2
|
import ApplicationLifecycle, { ApplicationPhase } from "./ApplicationLifecycle";
|
|
3
|
-
import { CreateComponentPartitionTable } from "database/DatabaseHelper";
|
|
3
|
+
import { CreateComponentPartitionTable, UpdateComponentIndexes } from "database/DatabaseHelper";
|
|
4
4
|
import { GetSchema } from "database/DatabaseHelper";
|
|
5
5
|
import { logger as MainLogger } from "./Logger";
|
|
6
6
|
const logger = MainLogger.child({ scope: "ComponentRegistry" });
|
|
@@ -94,13 +94,14 @@ class ComponentRegistry {
|
|
|
94
94
|
return new Promise<boolean>(async resolve => {
|
|
95
95
|
const partitionTableName = `components_${this.sluggifyName(name)}`;
|
|
96
96
|
await this.populateCurrentTables();
|
|
97
|
+
const instance = new ctor();
|
|
98
|
+
const indexedProps = instance.indexedProperties();
|
|
97
99
|
if (!this.currentTables.includes(partitionTableName)) {
|
|
98
100
|
logger.trace(`Partition table ${partitionTableName} does not exist. Creating... name: ${name}, typeId: ${typeid}`);
|
|
99
|
-
const instance = new ctor();
|
|
100
|
-
const indexedProps = instance.indexedProperties();
|
|
101
101
|
await CreateComponentPartitionTable(name, typeid, indexedProps);
|
|
102
102
|
await this.populateCurrentTables();
|
|
103
103
|
}
|
|
104
|
+
await UpdateComponentIndexes(partitionTableName, indexedProps);
|
|
104
105
|
this.componentsMap.set(name, typeid);
|
|
105
106
|
this.typeIdToCtor.set(typeid, ctor);
|
|
106
107
|
resolve(true);
|