bunsane 0.1.1 → 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/core/App.ts CHANGED
@@ -140,55 +140,104 @@ export default class App {
140
140
  private async handleRequest(req: Request): Promise<Response> {
141
141
  const url = new URL(req.url);
142
142
  const method = req.method;
143
+ const startTime = Date.now();
143
144
 
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);
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);
153
176
  }
154
- } catch (error) {
155
- logger.error(`Error serving static file ${filePath}:`, error as any);
156
177
  }
157
178
  }
158
- }
159
179
 
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), {
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,
170
203
  headers: { 'Content-Type': 'application/json' }
171
204
  });
172
205
  }
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,
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,
177
226
  headers: { 'Content-Type': 'application/json' }
178
227
  });
179
228
  }
229
+
230
+ return new Response(JSON.stringify({ error: 'Internal server error' }), {
231
+ status: 500,
232
+ headers: { 'Content-Type': 'application/json' }
233
+ });
180
234
  }
181
-
182
- if (this.yoga) {
183
- return this.yoga(req);
184
- }
185
-
186
- return new Response('Not Found', { status: 404 });
187
235
  }
188
236
 
189
237
  async start() {
190
238
  logger.info("Application Started");
191
239
  const server = Bun.serve({
240
+ port: parseInt(process.env.PORT || "3000"),
192
241
  fetch: this.handleRequest.bind(this),
193
242
  });
194
243
  logger.info(`Server is running on ${new URL(this.yoga?.graphqlEndpoint || '/graphql', `http://${server.hostname}:${server.port}`)}`)
package/core/Entity.ts CHANGED
@@ -14,6 +14,7 @@ export class Entity {
14
14
  id: string;
15
15
  public _persisted: boolean = false;
16
16
  private components: Map<string, BaseComponent> = new Map<string, BaseComponent>();
17
+ private removedComponents: Set<string> = new Set<string>();
17
18
  protected _dirty: boolean = false;
18
19
 
19
20
  constructor(id?: string) {
@@ -91,11 +92,17 @@ export class Entity {
91
92
  /**
92
93
  * Removes a component from the entity.
93
94
  * Use like: entity.remove(Component)
95
+ * WARNING: This will delete the component from the database upon saving the entity.
96
+ * If you want to keep the component in the database but just remove it from the entity instance,
97
+ * consider implementing a different method.
94
98
  */
95
99
  public remove<T extends BaseComponent>(ctor: new (...args: any[]) => T): boolean {
96
100
  const component = Array.from(this.components.values()).find(comp => comp instanceof ctor) as T;
97
101
 
98
102
  if (component) {
103
+ // Track the component type for database deletion
104
+ this.removedComponents.add(component.getTypeID());
105
+
99
106
  // Remove the component from the map
100
107
  this.components.delete(component.getTypeID());
101
108
  this._dirty = true;
@@ -150,7 +157,23 @@ export class Entity {
150
157
 
151
158
  @timed("Entity.save")
152
159
  public save() {
153
- return EntityManager.saveEntity(this);
160
+ return new Promise<boolean>((resolve, reject) => {
161
+ // Add timeout to prevent hanging
162
+ const timeout = setTimeout(() => {
163
+ logger.error(`Entity save timeout for entity ${this.id}`);
164
+ reject(new Error(`Entity save timeout for entity ${this.id}`));
165
+ }, 30000); // 30 second timeout
166
+
167
+ this.doSave()
168
+ .then(result => {
169
+ clearTimeout(timeout);
170
+ resolve(result);
171
+ })
172
+ .catch(error => {
173
+ clearTimeout(timeout);
174
+ reject(error);
175
+ });
176
+ });
154
177
  }
155
178
 
156
179
 
@@ -158,7 +181,7 @@ export class Entity {
158
181
  public doSave() {
159
182
  return new Promise<boolean>(async resolve => {
160
183
  if(!this._dirty) {
161
- console.log("Entity is not dirty, no need to save.");
184
+ logger.trace("Entity is not dirty, no need to save.");
162
185
  return resolve(true);
163
186
  }
164
187
 
@@ -170,6 +193,15 @@ export class Entity {
170
193
  await trx`INSERT INTO entities (id) VALUES (${this.id}) ON CONFLICT DO NOTHING`;
171
194
  this._persisted = true;
172
195
  }
196
+
197
+ // Delete removed components from database
198
+ if (this.removedComponents.size > 0) {
199
+ const typeIds = Array.from(this.removedComponents);
200
+ await trx`DELETE FROM components WHERE entity_id = ${this.id} AND type_id IN ${sql(typeIds)}`;
201
+ await trx`DELETE FROM entity_components WHERE entity_id = ${this.id} AND type_id IN ${sql(typeIds)}`;
202
+ this.removedComponents.clear();
203
+ }
204
+
173
205
  if(this.components.size === 0) {
174
206
  logger.trace(`No components to save for entity ${this.id}`);
175
207
  return;
package/core/Query.ts CHANGED
@@ -117,6 +117,10 @@ class Query {
117
117
 
118
118
  private buildFilterCondition(filter: QueryFilter, alias: string, paramIndex: number): { sql: string, params: any[], newParamIndex: number } {
119
119
  const { field, operator, value } = filter;
120
+
121
+ // Build JSON path for nested properties (e.g., "parent.child" -> data->'parent'->>'child')
122
+ const jsonPath = this.buildJsonPath(field, alias);
123
+
120
124
  switch (operator) {
121
125
  case "=":
122
126
  case ">":
@@ -125,22 +129,22 @@ class Query {
125
129
  case "<=":
126
130
  case "!=":
127
131
  if (typeof value === "string") {
128
- return { sql: `${alias}.data->>'${field}' ${operator} $${paramIndex}`, params: [value], newParamIndex: paramIndex + 1 };
132
+ return { sql: `${jsonPath} ${operator} $${paramIndex}`, params: [value], newParamIndex: paramIndex + 1 };
129
133
  } else {
130
- return { sql: `(${alias}.data->>'${field}')::numeric ${operator} $${paramIndex}`, params: [value], newParamIndex: paramIndex + 1 };
134
+ return { sql: `(${jsonPath})::numeric ${operator} $${paramIndex}`, params: [value], newParamIndex: paramIndex + 1 };
131
135
  }
132
136
  case "LIKE":
133
- return { sql: `${alias}.data->>'${field}' LIKE $${paramIndex}`, params: [value], newParamIndex: paramIndex + 1 };
137
+ return { sql: `${jsonPath} LIKE $${paramIndex}`, params: [value], newParamIndex: paramIndex + 1 };
134
138
  case "IN":
135
139
  if (Array.isArray(value)) {
136
140
  const placeholders = Array.from({length: value.length}, (_, i) => `$${paramIndex + i}`).join(', ');
137
- return { sql: `${alias}.data->>'${field}' IN (${placeholders})`, params: value, newParamIndex: paramIndex + value.length };
141
+ return { sql: `${jsonPath} IN (${placeholders})`, params: value, newParamIndex: paramIndex + value.length };
138
142
  }
139
143
  throw new Error("IN operator requires an array of values");
140
144
  case "NOT IN":
141
145
  if (Array.isArray(value)) {
142
146
  const placeholders = Array.from({length: value.length}, (_, i) => `$${paramIndex + i}`).join(', ');
143
- return { sql: `${alias}.data->>'${field}' NOT IN (${placeholders})`, params: value, newParamIndex: paramIndex + value.length };
147
+ return { sql: `${jsonPath} NOT IN (${placeholders})`, params: value, newParamIndex: paramIndex + value.length };
144
148
  }
145
149
  throw new Error("NOT IN operator requires an array of values");
146
150
  default:
@@ -148,6 +152,27 @@ class Query {
148
152
  }
149
153
  }
150
154
 
155
+ /**
156
+ * Build PostgreSQL JSON path expression for nested properties
157
+ * @param field Field path (e.g., "parent.child.grandchild")
158
+ * @param alias Table alias for the components table
159
+ * @returns PostgreSQL JSON path expression
160
+ */
161
+ private buildJsonPath(field: string, alias: string): string {
162
+ const parts = field.split('.');
163
+
164
+ if (parts.length === 1) {
165
+ // Single level: data->>'field'
166
+ return `${alias}.data->>'${field}'`;
167
+ } else {
168
+ // Nested levels: data->'parent'->'child'->>'grandchild'
169
+ const pathParts = parts.slice(0, -1).map(part => `'${part}'`);
170
+ const lastPart = parts[parts.length - 1];
171
+
172
+ return `${alias}.data->${pathParts.join('->')}->>'${lastPart}'`;
173
+ }
174
+ }
175
+
151
176
  private buildFilterWhereClause(typeId: string, filters: QueryFilter[], alias: string, paramIndex: number): { sql: string, params: any[], newParamIndex: number } {
152
177
  if (filters.length === 0) return { sql: '', params: [], newParamIndex: paramIndex };
153
178
 
@@ -235,6 +260,26 @@ class Query {
235
260
 
236
261
  @timed("Query.exec")
237
262
  public async exec(): Promise<Entity[]> {
263
+ return new Promise<Entity[]>((resolve, reject) => {
264
+ // Add timeout to prevent hanging queries
265
+ const timeout = setTimeout(() => {
266
+ logger.error(`Query execution timeout`);
267
+ reject(new Error(`Query execution timeout after 30 seconds`));
268
+ }, 30000); // 30 second timeout
269
+
270
+ this.doExec()
271
+ .then(result => {
272
+ clearTimeout(timeout);
273
+ resolve(result);
274
+ })
275
+ .catch(error => {
276
+ clearTimeout(timeout);
277
+ reject(error);
278
+ });
279
+ });
280
+ }
281
+
282
+ private async doExec(): Promise<Entity[]> {
238
283
  const componentIds = Array.from(this.requiredComponents);
239
284
  const excludedIds = Array.from(this.excludedComponents);
240
285
  const componentCount = componentIds.length;
@@ -422,7 +467,10 @@ class Query {
422
467
  const componentAlias = `c_${typeId}`;
423
468
  const direction = order.direction.toUpperCase();
424
469
  const nulls = order.nullsFirst ? 'NULLS FIRST' : 'NULLS LAST';
425
- orderClauses.push(`(${componentAlias}.data->>'${order.property}')::text ${direction} ${nulls}`);
470
+
471
+ // Use buildJsonPath for nested property support
472
+ const jsonPath = this.buildJsonPath(order.property, componentAlias);
473
+ orderClauses.push(`(${jsonPath})::text ${direction} ${nulls}`);
426
474
  }
427
475
 
428
476
  // Always include entity_id as final tiebreaker for consistent ordering
@@ -475,7 +523,9 @@ class Query {
475
523
  const direction = order.direction.toUpperCase();
476
524
  const nullsClause = order.nullsFirst ? "NULLS FIRST" : "NULLS LAST";
477
525
 
478
- const selectExpr = `, (${alias}.data->>'${order.property}')::numeric as sort_val`;
526
+ // Use buildJsonPath for nested property support
527
+ const jsonPath = this.buildJsonPath(order.property, alias);
528
+ const selectExpr = `, (${jsonPath})::numeric as sort_val`;
479
529
  const orderByExpr = `ORDER BY sort_val ${direction} ${nullsClause}, ec.entity_id ASC`;
480
530
 
481
531
  return { select: selectExpr, orderBy: orderByExpr };
@@ -18,46 +18,70 @@ export type RequestLoaders = {
18
18
 
19
19
  export function createRequestLoaders(db: any): RequestLoaders {
20
20
  const entityById = new DataLoader<string, Entity | null>(async (ids: readonly string[]) => {
21
- const uniqueIds = [...new Set(ids)];
22
- const rows = await db`
23
- SELECT id
24
- FROM entities
25
- WHERE id IN ${inList(uniqueIds)}
26
- AND deleted_at IS NULL
27
- `;
28
- const entities = rows.map((row: any) => {
29
- const entity = new Entity(row.id);
30
- entity.setPersisted(true);
31
- return entity;
32
- });
33
- const map = new Map<string, Entity>();
34
- entities.forEach((e: Entity) => map.set(e.id, e));
35
- return ids.map(id => map.get(id) ?? null);
21
+ const startTime = Date.now();
22
+ try {
23
+ const uniqueIds = [...new Set(ids)];
24
+ const rows = await db`
25
+ SELECT id
26
+ FROM entities
27
+ WHERE id IN ${inList(uniqueIds, 1)}
28
+ AND deleted_at IS NULL
29
+ `;
30
+ const entities = rows.map((row: any) => {
31
+ const entity = new Entity(row.id);
32
+ entity.setPersisted(true);
33
+ return entity;
34
+ });
35
+ const map = new Map<string, Entity>();
36
+ entities.forEach((e: Entity) => map.set(e.id, e));
37
+
38
+ const duration = Date.now() - startTime;
39
+ if (duration > 1000) { // Log slow queries
40
+ console.warn(`Slow entityById query: ${duration}ms for ${ids.length} entities`);
41
+ }
42
+
43
+ return ids.map(id => map.get(id) ?? null);
44
+ } catch (error) {
45
+ console.error(`Error in entityById DataLoader:`, error);
46
+ throw error;
47
+ }
36
48
  });
37
49
 
38
50
  const componentsByEntityType = new DataLoader<{ entityId: string; typeId: number }, ComponentData | null>(
39
51
  async (keys: readonly { entityId: string; typeId: number }[]) => {
40
- const entityIds = [...new Set(keys.map(k => k.entityId))];
41
- const typeIds = [...new Set(keys.map(k => k.typeId))];
42
- const rows = await db`
43
- SELECT entity_id, type_id, data, created_at, updated_at, deleted_at
44
- FROM components
45
- WHERE entity_id IN ${inList(entityIds)}
46
- AND type_id IN ${inList(typeIds)}
47
- AND deleted_at IS NULL
48
- `;
49
- const map = new Map<string, ComponentData>();
50
- rows.forEach((row: any) => {
51
- const key = `${row.entity_id}-${row.type_id}`;
52
- map.set(key, {
53
- typeId: row.type_id,
54
- data: row.data,
55
- createdAt: row.created_at,
56
- updatedAt: row.updated_at,
57
- deletedAt: row.deleted_at,
52
+ const startTime = Date.now();
53
+ try {
54
+ const entityIds = [...new Set(keys.map(k => k.entityId))];
55
+ const typeIds = [...new Set(keys.map(k => k.typeId))];
56
+ const rows = await db`
57
+ SELECT entity_id, type_id, data, created_at, updated_at, deleted_at
58
+ FROM components
59
+ WHERE entity_id IN ${inList(entityIds, 1)}
60
+ AND type_id IN ${inList(typeIds, entityIds.length + 1)}
61
+ AND deleted_at IS NULL
62
+ `;
63
+ const map = new Map<string, ComponentData>();
64
+ rows.forEach((row: any) => {
65
+ const key = `${row.entity_id}-${row.type_id}`;
66
+ map.set(key, {
67
+ typeId: row.type_id,
68
+ data: row.data,
69
+ createdAt: row.created_at,
70
+ updatedAt: row.updated_at,
71
+ deletedAt: row.deleted_at,
72
+ });
58
73
  });
59
- });
60
- return keys.map(k => map.get(`${k.entityId}-${k.typeId}`) ?? null);
74
+
75
+ const duration = Date.now() - startTime;
76
+ if (duration > 1000) { // Log slow queries
77
+ console.warn(`Slow componentsByEntityType query: ${duration}ms for ${keys.length} keys`);
78
+ }
79
+
80
+ return keys.map(k => map.get(`${k.entityId}-${k.typeId}`) ?? null);
81
+ } catch (error) {
82
+ console.error(`Error in componentsByEntityType DataLoader:`, error);
83
+ throw error;
84
+ }
61
85
  }
62
86
  );
63
87
 
package/database/index.ts CHANGED
@@ -2,11 +2,11 @@ import {SQL} from "bun";
2
2
  import { logger } from "core/Logger";
3
3
 
4
4
  const db = new SQL({
5
- url: `postgres://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST}:5432/${process.env.POSTGRES_DB}`,
6
- // Connection pool settings
7
- max: parseInt(process.env.POSTGRES_MAX_CONNECTIONS ?? '10', 10), // Maximum connections in pool
8
- idleTimeout: 0, // Close idle connections after 30s
9
- maxLifetime: 0, // Connection lifetime in seconds (0 = forever)
5
+ url: `postgres://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST}:${process.env.POSTGRES_PORT ?? "5432"}/${process.env.POSTGRES_DB}`,
6
+ // Connection pool settings - FIXED
7
+ max: parseInt(process.env.POSTGRES_MAX_CONNECTIONS ?? '20', 10), // Increased max connections
8
+ idleTimeout: 30000, // Close idle connections after 30s (was 0)
9
+ maxLifetime: 600000, // Connection lifetime 10 minutes (was 0 = forever)
10
10
  connectionTimeout: 30, // Timeout when establishing new connections
11
11
  onclose: (err) => {
12
12
  if (err) {
package/gql/Generator.ts CHANGED
@@ -57,7 +57,7 @@ export function generateGraphQLSchema(services: any[]): { schema: GraphQLSchema
57
57
  let typeDefs = `
58
58
  `;
59
59
  const scalarTypes: Set<string> = new Set();
60
- const resolvers: any = { Query: {}, Mutation: {} };
60
+ const resolvers: any = {};
61
61
  const queryFields: string[] = [];
62
62
  const mutationFields: string[] = [];
63
63
 
@@ -80,6 +80,7 @@ export function generateGraphQLSchema(services: any[]): { schema: GraphQLSchema
80
80
  if (service.__graphqlOperations) {
81
81
  service.__graphqlOperations.forEach((op: any) => {
82
82
  const { type, name, input, output, propertyKey } = op;
83
+ if (!resolvers[type]) resolvers[type] = {};
83
84
  let fieldDef = `${name}`;
84
85
  if (input) {
85
86
  const inputName = `${name}Input`;
package/gql/index.ts CHANGED
@@ -71,9 +71,19 @@ const staticResolvers = {
71
71
  };
72
72
 
73
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
+
74
84
  // Handle JWT authentication errors specifically
75
85
  if (error.extensions?.code === 'DOWNSTREAM_SERVICE_ERROR' && error.extensions?.http?.status === 401) {
76
- return new GraphQLError('Error: Unauthorized', {
86
+ return new GraphQLError('Unauthorized', {
77
87
  extensions: {
78
88
  code: 'UNAUTHORIZED',
79
89
  http: { status: 401 }
package/package.json CHANGED
@@ -1,9 +1,17 @@
1
1
  {
2
2
  "name": "bunsane",
3
- "version": "0.1.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,13 +21,13 @@
13
21
  "typescript": "^5"
14
22
  },
15
23
  "dependencies": {
16
- "dataloader": "^2.2.2",
17
- "graphql": "^16.11.0",
18
- "graphql-yoga": "^5.15.1",
19
- "pino": "^9.9.0",
20
- "pino-pretty": "^13.1.1",
21
- "reflect-metadata": "^0.2.2",
22
- "zod": "^4.1.5"
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"
23
31
  },
24
32
  "repository": {
25
33
  "type": "git",