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.
Files changed (82) hide show
  1. package/.github/workflows/deploy-docs.yml +57 -0
  2. package/LICENSE.md +1 -1
  3. package/README.md +2 -28
  4. package/TODO.md +8 -1
  5. package/bun.lock +3 -0
  6. package/config/upload.config.ts +135 -0
  7. package/core/App.ts +168 -4
  8. package/core/ArcheType.ts +122 -0
  9. package/core/BatchLoader.ts +100 -0
  10. package/core/ComponentRegistry.ts +4 -3
  11. package/core/Components.ts +2 -2
  12. package/core/Decorators.ts +15 -8
  13. package/core/Entity.ts +193 -14
  14. package/core/EntityCache.ts +15 -0
  15. package/core/EntityHookManager.ts +855 -0
  16. package/core/EntityManager.ts +12 -2
  17. package/core/ErrorHandler.ts +64 -7
  18. package/core/FileValidator.ts +284 -0
  19. package/core/Query.ts +503 -85
  20. package/core/RequestContext.ts +24 -0
  21. package/core/RequestLoaders.ts +89 -0
  22. package/core/SchedulerManager.ts +710 -0
  23. package/core/UploadManager.ts +261 -0
  24. package/core/components/UploadComponent.ts +206 -0
  25. package/core/decorators/EntityHooks.ts +190 -0
  26. package/core/decorators/ScheduledTask.ts +83 -0
  27. package/core/events/EntityLifecycleEvents.ts +177 -0
  28. package/core/processors/ImageProcessor.ts +423 -0
  29. package/core/storage/LocalStorageProvider.ts +290 -0
  30. package/core/storage/StorageProvider.ts +112 -0
  31. package/database/DatabaseHelper.ts +183 -58
  32. package/database/index.ts +5 -5
  33. package/database/sqlHelpers.ts +7 -0
  34. package/docs/README.md +149 -0
  35. package/docs/_coverpage.md +36 -0
  36. package/docs/_sidebar.md +23 -0
  37. package/docs/api/core.md +568 -0
  38. package/docs/api/hooks.md +554 -0
  39. package/docs/api/index.md +222 -0
  40. package/docs/api/query.md +678 -0
  41. package/docs/api/service.md +744 -0
  42. package/docs/core-concepts/archetypes.md +512 -0
  43. package/docs/core-concepts/components.md +498 -0
  44. package/docs/core-concepts/entity.md +314 -0
  45. package/docs/core-concepts/hooks.md +683 -0
  46. package/docs/core-concepts/query.md +588 -0
  47. package/docs/core-concepts/services.md +647 -0
  48. package/docs/examples/code-examples.md +425 -0
  49. package/docs/getting-started.md +337 -0
  50. package/docs/index.html +97 -0
  51. package/gql/Generator.ts +58 -35
  52. package/gql/decorators/Upload.ts +176 -0
  53. package/gql/helpers.ts +67 -0
  54. package/gql/index.ts +65 -31
  55. package/gql/types.ts +1 -1
  56. package/index.ts +79 -11
  57. package/package.json +19 -10
  58. package/rest/Generator.ts +3 -0
  59. package/rest/index.ts +22 -0
  60. package/service/Service.ts +1 -1
  61. package/service/ServiceRegistry.ts +10 -6
  62. package/service/index.ts +12 -1
  63. package/tests/bench/insert.bench.ts +59 -0
  64. package/tests/bench/relations.bench.ts +269 -0
  65. package/tests/bench/sorting.bench.ts +415 -0
  66. package/tests/component-hooks.test.ts +1409 -0
  67. package/tests/component.test.ts +338 -0
  68. package/tests/errorHandling.test.ts +155 -0
  69. package/tests/hooks.test.ts +666 -0
  70. package/tests/query-sorting.test.ts +101 -0
  71. package/tests/relations.test.ts +169 -0
  72. package/tests/scheduler.test.ts +724 -0
  73. package/tsconfig.json +35 -34
  74. package/types/graphql.types.ts +87 -0
  75. package/types/hooks.types.ts +141 -0
  76. package/types/scheduler.types.ts +165 -0
  77. package/types/upload.types.ts +184 -0
  78. package/upload/index.ts +140 -0
  79. package/utils/UploadHelper.ts +305 -0
  80. package/utils/cronParser.ts +366 -0
  81. package/utils/errorMessages.ts +151 -0
  82. 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
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 BunSane contributors
3
+ Copyright (c) 2025 Yaaruu
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- <img src="./BunSane.jpg" alt="BunSane" width="520" />
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
- Requires Bun and PostgreSQL.
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
- // TODO: Add index when component was registered but changed
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);
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
+ }
45
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,119 @@ 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
+ const startTime = Date.now();
144
+
145
+ // Add request timeout
146
+ const controller = new AbortController();
147
+ const timeoutId = setTimeout(() => {
148
+ controller.abort();
149
+ logger.warn(`Request timeout: ${method} ${url.pathname}`);
150
+ }, 30000); // 30 second timeout
151
+
152
+ try {
153
+ // Health check endpoint
154
+ if (url.pathname === '/health') {
155
+ clearTimeout(timeoutId);
156
+ return new Response(JSON.stringify({
157
+ status: 'ok',
158
+ timestamp: new Date().toISOString(),
159
+ uptime: process.uptime()
160
+ }), {
161
+ headers: { 'Content-Type': 'application/json' }
162
+ });
163
+ }
164
+ for (const [route, folder] of this.staticAssets) {
165
+ if (url.pathname.startsWith(route)) {
166
+ const relativePath = url.pathname.slice(route.length);
167
+ const filePath = path.join(folder, relativePath);
168
+ try {
169
+ const file = Bun.file(filePath);
170
+ if (await file.exists()) {
171
+ clearTimeout(timeoutId);
172
+ return new Response(file);
173
+ }
174
+ } catch (error) {
175
+ logger.error(`Error serving static file ${filePath}:`, error as any);
176
+ }
177
+ }
178
+ }
179
+
180
+ // Lookup REST endpoint using map for O(1) performance
181
+ const endpointKey = `${method}:${url.pathname}`;
182
+ const endpoint = this.restEndpointMap.get(endpointKey);
183
+ if (endpoint) {
184
+ try {
185
+ const result = await endpoint.handler(req);
186
+ const duration = Date.now() - startTime;
187
+ logger.trace(`REST ${method} ${url.pathname} completed in ${duration}ms`);
188
+
189
+ clearTimeout(timeoutId);
190
+ if (result instanceof Response) {
191
+ return result;
192
+ } else {
193
+ return new Response(JSON.stringify(result), {
194
+ headers: { 'Content-Type': 'application/json' }
195
+ });
196
+ }
197
+ } catch (error) {
198
+ const duration = Date.now() - startTime;
199
+ logger.error(`Error in REST endpoint ${method} ${endpoint.path} after ${duration}ms`, error as any);
200
+ clearTimeout(timeoutId);
201
+ return new Response(JSON.stringify({ error: 'Internal server error' }), {
202
+ status: 500,
203
+ headers: { 'Content-Type': 'application/json' }
204
+ });
205
+ }
206
+ }
207
+
208
+ if (this.yoga) {
209
+ const response = await this.yoga(req);
210
+ const duration = Date.now() - startTime;
211
+ logger.trace(`GraphQL request completed in ${duration}ms`);
212
+ clearTimeout(timeoutId);
213
+ return response;
214
+ }
215
+
216
+ clearTimeout(timeoutId);
217
+ return new Response('Not Found', { status: 404 });
218
+ } catch (error) {
219
+ const duration = Date.now() - startTime;
220
+ logger.error(`Request failed after ${duration}ms: ${method} ${url.pathname}`, error as any);
221
+ clearTimeout(timeoutId);
222
+
223
+ if ((error as Error).name === 'AbortError') {
224
+ return new Response(JSON.stringify({ error: 'Request timeout' }), {
225
+ status: 408,
226
+ headers: { 'Content-Type': 'application/json' }
227
+ });
228
+ }
229
+
230
+ return new Response(JSON.stringify({ error: 'Internal server error' }), {
231
+ status: 500,
232
+ headers: { 'Content-Type': 'application/json' }
233
+ });
234
+ }
235
+ }
236
+
74
237
  async start() {
75
238
  logger.info("Application Started");
76
239
  const server = Bun.serve({
77
- fetch: this.yoga
240
+ port: parseInt(process.env.PORT || "3000"),
241
+ fetch: this.handleRequest.bind(this),
78
242
  });
79
- logger.info(`Server is running on ${new URL(this.yoga.graphqlEndpoint, `http://${server.hostname}:${server.port}`)}`)
243
+ logger.info(`Server is running on ${new URL(this.yoga?.graphqlEndpoint || '/graphql', `http://${server.hostname}:${server.port}`)}`)
80
244
  }
81
245
  }
@@ -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;