bunsane 0.1.0

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/Entity.ts ADDED
@@ -0,0 +1,188 @@
1
+ import type { ComponentDataType, ComponentGetter, BaseComponent } from "./Components";
2
+ import { logger } from "./Logger";
3
+ import db from "database";
4
+ import EntityManager from "./EntityManager";
5
+ import ComponentRegistry from "./ComponentRegistry";
6
+ import { uuidv7 } from "utils/uuid";
7
+ import { sql } from "bun";
8
+ import Query from "./Query";
9
+
10
+
11
+ export class Entity {
12
+ id: string;
13
+ public _persisted: boolean = false;
14
+ private components: Map<string, BaseComponent> = new Map<string, BaseComponent>();
15
+ protected _dirty: boolean = false;
16
+
17
+ constructor(id?: string) {
18
+ this.id = id ?? uuidv7();
19
+ this._dirty = true;
20
+ }
21
+
22
+ public static Create(): Entity {
23
+ return new Entity();
24
+ }
25
+
26
+ private addComponent(component: BaseComponent): Entity {
27
+ this.components.set(component.getTypeID(), component);
28
+ return this;
29
+ }
30
+
31
+ public componentList(): BaseComponent[] {
32
+ return Array.from(this.components.values());
33
+ }
34
+
35
+ /**
36
+ * Adds a new component to the entity.
37
+ * Use like: entity.add(Component, { value: "Test" })
38
+ */
39
+ public add<T extends BaseComponent>(ctor: new (...args: any[]) => T, data: ComponentDataType<T>): this {
40
+ const instance = new ctor();
41
+ Object.assign(instance, data);
42
+ this.addComponent(instance);
43
+ return this;
44
+ }
45
+
46
+ /**
47
+ * Sets/updates a component on the entity.
48
+ * If the component exists, it updates its properties.
49
+ * If it doesn't exist, it adds a new component.
50
+ * Use like: entity.set(Component, { value: "Test" })
51
+ */
52
+ public async set<T extends BaseComponent>(ctor: new (...args: any[]) => T, data: Record<string, any>): Promise<this> {
53
+ await this.get(ctor);
54
+
55
+ const component = Array.from(this.components.values()).find(comp => comp instanceof ctor) as T;
56
+ if (component) {
57
+ console.log("Updating Existing Component", component.getTypeID())
58
+ // Update existing component
59
+ Object.assign(component, data);
60
+ component.setDirty(true);
61
+ this._dirty = true;
62
+ } else {
63
+ // Add new component
64
+ console.log("Adding New Component")
65
+ this.add(ctor, data as any);
66
+ this._dirty = true;
67
+ }
68
+ return this;
69
+ }
70
+ /**
71
+ * Get component from entities. If entity is populated in query the component will get within the entitiy
72
+ * If not it will fetch from database
73
+ * @param Component
74
+ * @returns `Component | null` *if entity doesn't have the component
75
+ */
76
+ public async get<T extends BaseComponent>(ctor: new (...args: any[]) => T): Promise<ComponentDataType<T> | null> {
77
+ const comp = Array.from(this.components.values()).find(comp => comp instanceof ctor) as ComponentGetter<T> | undefined;
78
+ if(typeof comp !== "undefined") {
79
+ return comp.data();
80
+ } else {
81
+ // fetch from db
82
+ const temp = new ctor();
83
+ const typeId = temp.getTypeID();
84
+ try {
85
+ const rows = await db`SELECT id, data FROM components WHERE entity_id = ${this.id} AND type_id = ${typeId}`;
86
+ if (rows.length > 0) {
87
+ const row = rows[0];
88
+ const comp = new ctor();
89
+ Object.assign(comp, row.data);
90
+ comp.id = row.id;
91
+ comp.setPersisted(true);
92
+ comp.setDirty(false);
93
+ this.addComponent(comp);
94
+ return comp.data();
95
+ } else {
96
+ return null;
97
+ }
98
+ } catch (error) {
99
+ logger.error(`Failed to fetch component: ${error}`);
100
+ return null;
101
+ }
102
+ }
103
+ }
104
+
105
+ public save() {
106
+ return EntityManager.saveEntity(this);
107
+ }
108
+
109
+ public doSave() {
110
+ return new Promise<boolean>(async resolve => {
111
+ if(!this._dirty) {
112
+ console.log("Entity is not dirty, no need to save.");
113
+ return resolve(true);
114
+ }
115
+ await db.transaction(async (trx) => {
116
+ if(!this._persisted) {
117
+ await trx`INSERT INTO entities (id) VALUES (${this.id}) ON CONFLICT DO NOTHING`;
118
+ this._persisted = true;
119
+ }
120
+ if(this.components.size === 0) {
121
+ logger.trace(`No components to save for entity ${this.id}`);
122
+ return;
123
+ }
124
+ const waitable = [];
125
+
126
+ for(const comp of this.components.values()) {
127
+ waitable.push(comp.save(trx, this.id));
128
+ }
129
+
130
+ await Promise.all(waitable);
131
+ });
132
+ this._dirty = false;
133
+ resolve(true);
134
+ })
135
+
136
+ }
137
+
138
+ public setPersisted(persisted: boolean) {
139
+ this._persisted = persisted;
140
+ }
141
+
142
+ public setDirty(dirty: boolean) {
143
+ this._dirty = dirty;
144
+ }
145
+
146
+ public static async LoadMultiple(ids: string[]): Promise<Entity[]> {
147
+ if (ids.length === 0) return [];
148
+
149
+ const components = await db`
150
+ SELECT c.id, c.entity_id, c.type_id, c.data
151
+ FROM components c
152
+ WHERE c.entity_id IN ${sql(ids)}
153
+ `;
154
+
155
+ const entitiesMap = new Map<string, Entity>();
156
+
157
+ for (const id of ids) {
158
+ const entity = new Entity();
159
+ entity.id = id;
160
+ entity.setPersisted(true);
161
+ entity.setDirty(false);
162
+ entitiesMap.set(id, entity);
163
+ }
164
+
165
+ for (const row of components) {
166
+ const { id, entity_id, type_id, data } = row;
167
+ const ctor = ComponentRegistry.getConstructor(type_id);
168
+ if (ctor) {
169
+ const comp = new ctor();
170
+ Object.assign(comp, data);
171
+ comp.id = id;
172
+ comp.setPersisted(true);
173
+ comp.setDirty(false);
174
+ entitiesMap.get(entity_id)?.addComponent(comp);
175
+ }
176
+ }
177
+
178
+ return Array.from(entitiesMap.values());
179
+ }
180
+
181
+ public static async FindById(id: string): Promise<Entity | null> {
182
+ const entities = await new Query().findById(id).populate().exec()
183
+ if(entities.length === 1) {
184
+ return entities[0]!;
185
+ }
186
+ return null;
187
+ }
188
+ }
@@ -0,0 +1,46 @@
1
+ import ApplicationLifecycle, { ApplicationPhase } from "./ApplicationLifecycle";
2
+ import type { Entity } from "./Entity";
3
+
4
+ class EntityManager {
5
+ static #instance: EntityManager;
6
+ private dbReady = false;
7
+ private entityQueue: Entity[] = [];
8
+
9
+ constructor() {
10
+ ApplicationLifecycle.addPhaseListener(async (event) => {
11
+ if (event.detail === ApplicationPhase.DATABASE_READY) {
12
+ this.dbReady = true;
13
+ await this.savePendingEntities();
14
+ }
15
+ });
16
+ }
17
+
18
+ public saveEntity(entity: Entity) {
19
+ return new Promise<boolean>(async resolve => {
20
+ if(!this.dbReady) {
21
+ this.entityQueue.push(entity);
22
+ return resolve(true);
23
+ } else {
24
+ resolve(entity.doSave());
25
+ }
26
+ })
27
+
28
+ }
29
+
30
+ private async savePendingEntities() {
31
+ const promiseWait = [];
32
+ for(const entity of this.entityQueue) {
33
+ promiseWait.push(entity.doSave());
34
+ }
35
+ return await Promise.all(promiseWait);
36
+ }
37
+
38
+ public static get instance(): EntityManager {
39
+ if (!this.#instance) {
40
+ this.#instance = new EntityManager();
41
+ }
42
+ return this.#instance;
43
+ }
44
+ }
45
+
46
+ export default EntityManager.instance;
@@ -0,0 +1,35 @@
1
+ import * as z from "zod";
2
+ import { GraphQLError, type GraphQLErrorOptions } from "graphql";
3
+
4
+ export function responseError(message: string, extensions?: GraphQLErrorOptions) {
5
+ return new GraphQLError(message, {
6
+ extensions: {
7
+ code: "UNKNOWN_ERROR",
8
+ },
9
+ ...extensions
10
+ });
11
+ }
12
+
13
+ export function handleGraphQLError(err: any): never {
14
+ if (err instanceof z.ZodError) {
15
+ const errorMessages = err.issues.map((error: any) =>
16
+ `${error.path.join('.')}: ${error.message}`
17
+ ).join(', ');
18
+
19
+ throw new GraphQLError(`Validation failed: ${errorMessages}`, {
20
+ extensions: {
21
+ code: "VALIDATION_ERROR",
22
+ validationErrors: err.issues
23
+ }
24
+ });
25
+ }
26
+ if (err instanceof GraphQLError) {
27
+ throw err;
28
+ }
29
+ throw new GraphQLError("An unexpected error occurred", {
30
+ extensions: {
31
+ code: "INTERNAL_ERROR",
32
+ originalError: process.env.NODE_ENV === 'development' ? err : undefined
33
+ }
34
+ });
35
+ }
package/core/Events.ts ADDED
File without changes
package/core/Logger.ts ADDED
@@ -0,0 +1,16 @@
1
+ import pino from "pino";
2
+
3
+ const usePretty = process.env.LOG_PRETTY === 'true';
4
+ export const logger = pino({
5
+ level: process.env.LOG_LEVEL || 'info',
6
+ ...(usePretty && {
7
+ transport: {
8
+ target: 'pino-pretty',
9
+ options: {
10
+ colorize: true,
11
+ messageFormat: '[{scope}{component}] => {msg}',
12
+ ignore: 'pid,hostname,scope'
13
+ }
14
+ }
15
+ })
16
+ });
package/core/Query.ts ADDED
@@ -0,0 +1,296 @@
1
+ import type { BaseComponent } from "./Components";
2
+ import { Entity } from "./Entity";
3
+ import ComponentRegistry from "./ComponentRegistry";
4
+ import { logger } from "./Logger";
5
+ import { sql } from "bun";
6
+ import db from "database";
7
+ import { timed } from "./Decorators";
8
+
9
+ export type FilterOperator = "=" | ">" | "<" | ">=" | "<=" | "!=" | "LIKE" | "IN" | "NOT IN";
10
+ export interface QueryFilter {
11
+ field: string;
12
+ operator: FilterOperator;
13
+ value: any;
14
+ }
15
+
16
+ export type QueryFilterOptions = {
17
+ filters?: QueryFilter[];
18
+ };
19
+
20
+ function wrapLog(str: string) {
21
+ // console.log(str);
22
+ }
23
+ class Query {
24
+ private requiredComponents: Set<string> = new Set<string>();
25
+ private excludedComponents: Set<string> = new Set<string>();
26
+ private componentFilters: Map<string, QueryFilter[]> = new Map();
27
+ private populateComponents: boolean = false;
28
+ private withId: string | null = null;
29
+
30
+ public findById(id: string) {
31
+ this.withId = id;
32
+ return this;
33
+ }
34
+
35
+ public async findOneById(id: string): Promise<Entity | null> {
36
+ const entities = await this.findById(id).exec();
37
+ return entities.length > 0 ? entities[0]! : null;
38
+ }
39
+
40
+ public with<T extends BaseComponent>(ctor: new (...args: any[]) => T, options?: QueryFilterOptions) {
41
+ const type_id = ComponentRegistry.getComponentId(ctor.name);
42
+ if(!type_id) {
43
+ throw new Error(`Component ${ctor.name} is not registered.`);
44
+ }
45
+ this.requiredComponents.add(type_id);
46
+
47
+ if (options?.filters && options.filters.length > 0) {
48
+ this.componentFilters.set(type_id, options.filters);
49
+ }
50
+
51
+ return this;
52
+ }
53
+
54
+
55
+ public static filter(field: string, operator: FilterOperator, value: any): QueryFilter {
56
+ return { field, operator, value };
57
+ }
58
+
59
+ public static filters(...filters: QueryFilter[]): QueryFilterOptions {
60
+ return { filters };
61
+ }
62
+
63
+ private buildFilterCondition(filter: QueryFilter): string {
64
+ const { field, operator, value } = filter;
65
+ switch (operator) {
66
+ case "=":
67
+ case ">":
68
+ case "<":
69
+ case ">=":
70
+ case "<=":
71
+ case "!=":
72
+ if (typeof value === "string") {
73
+ return `data->>'${field}' ${operator} '${value}'`;
74
+ } else {
75
+ return `(data->>'${field}')::numeric ${operator} ${value}`;
76
+ }
77
+ case "LIKE":
78
+ return `data->>'${field}' LIKE '${value}'`;
79
+ case "IN":
80
+ if (Array.isArray(value)) {
81
+ const valueList = value.map(v => typeof v === "string" ? `'${v}'` : v).join(", ");
82
+ return `data->>'${field}' IN (${valueList})`;
83
+ }
84
+ throw new Error("IN operator requires an array of values");
85
+ case "NOT IN":
86
+ if (Array.isArray(value)) {
87
+ const valueList = value.map(v => typeof v === "string" ? `'${v}'` : v).join(", ");
88
+ return `data->>'${field}' NOT IN (${valueList})`;
89
+ }
90
+ throw new Error("NOT IN operator requires an array of values");
91
+ default:
92
+ throw new Error(`Unsupported operator: ${operator}`);
93
+ }
94
+ }
95
+
96
+ private buildFilterWhereClause(typeId: string, filters: QueryFilter[]): string {
97
+ if (filters.length === 0) return "";
98
+
99
+ const conditions = filters.map(filter => this.buildFilterCondition(filter));
100
+ return conditions.join(" AND ");
101
+ }
102
+
103
+
104
+ public without<T extends BaseComponent>(ctor: new (...args: any[]) => T) {
105
+ const type_id = ComponentRegistry.getComponentId(ctor.name);
106
+ if(!type_id) {
107
+ throw new Error(`Component ${ctor.name} is not registered.`);
108
+ }
109
+ this.excludedComponents.add(type_id);
110
+ return this;
111
+ }
112
+
113
+ public populate(): this {
114
+ this.populateComponents = true;
115
+ return this;
116
+ }
117
+
118
+ @timed
119
+ public async exec(): Promise<Entity[]> {
120
+ const componentIds = Array.from(this.requiredComponents);
121
+ const excludedIds = Array.from(this.excludedComponents);
122
+ const componentCount = componentIds.length;
123
+ const hasRequired = componentCount > 0;
124
+ const hasExcluded = excludedIds.length > 0;
125
+ const hasFilters = this.componentFilters.size > 0;
126
+ const hasWithId = this.withId !== null;
127
+
128
+ let ids: string[] = [];
129
+
130
+ switch (true) {
131
+ case !hasRequired && !hasExcluded && !hasWithId:
132
+ return [];
133
+ case !hasRequired && !hasExcluded && hasWithId:
134
+ const result = await db`SELECT id FROM entities WHERE id = ${this.withId!}`;
135
+ ids = result.map((row: any) => row.id);
136
+ break;
137
+ case hasRequired && hasExcluded && hasFilters:
138
+ ids = await this.getIdsWithFiltersAndExclusions(componentIds, excludedIds, componentCount);
139
+ break;
140
+ case hasRequired && hasExcluded:
141
+ const componentIdsString = componentIds.map(id => `'${id}'`).join(', ');
142
+ const excludedIdsString = excludedIds.map(id => `'${id}'`).join(', ');
143
+ const excludedQuery = `
144
+ SELECT ec.entity_id as id
145
+ FROM entity_components ec
146
+ WHERE ec.type_id IN (${componentIdsString})
147
+ ${this.withId ? `AND ec.entity_id = '${this.withId}'` : ''}
148
+ AND NOT EXISTS (
149
+ SELECT 1 FROM entity_components ec_ex
150
+ WHERE ec_ex.entity_id = ec.entity_id AND ec_ex.type_id IN (${excludedIdsString})
151
+ )
152
+ GROUP BY ec.entity_id
153
+ HAVING COUNT(DISTINCT ec.type_id) = ${componentCount}
154
+ `;
155
+ wrapLog(`Executing query: ${excludedQuery}`);
156
+ const excludedQueryResult = await db.unsafe(excludedQuery);
157
+ ids = excludedQueryResult.map((row: any) => row.id);
158
+ break;
159
+ case hasRequired && hasFilters:
160
+ ids = await this.getIdsWithFilters(componentIds, componentCount);
161
+ break;
162
+ case hasRequired:
163
+ let queryStr: string;
164
+ let requiredOnlyQueryResult: any;
165
+ if (componentCount === 1) {
166
+ // Optimize for single component: no need for GROUP BY, HAVING, or DISTINCT
167
+ queryStr = `SELECT entity_id as id FROM entity_components WHERE type_id = '${componentIds[0]}' ${this.withId ? `AND entity_id = '${this.withId}'` : ''}`;
168
+ wrapLog(`Executing optimized query: ${queryStr}`);
169
+ requiredOnlyQueryResult = await db.unsafe(queryStr);
170
+ } else {
171
+ queryStr = `SELECT DISTINCT entity_id as id FROM entity_components WHERE type_id IN (${componentIds.map(id => `'${id}'`).join(', ')}) ${this.withId ? `AND entity_id = '${this.withId}'` : ''} GROUP BY entity_id HAVING COUNT(DISTINCT type_id) = ${componentCount}`;
172
+ wrapLog(`Executing query: ${queryStr}`);
173
+ requiredOnlyQueryResult = await db`
174
+ SELECT DISTINCT entity_id as id FROM entity_components
175
+ WHERE type_id IN ${sql(componentIds)}
176
+ ${this.withId ? sql`AND entity_id = ${this.withId}` : sql``}
177
+ GROUP BY entity_id
178
+ HAVING COUNT(DISTINCT type_id) = ${componentCount}
179
+ `;
180
+ }
181
+ ids = requiredOnlyQueryResult.map((row: any) => row.id);
182
+ break;
183
+ case hasExcluded:
184
+ const onlyExcludedIdsString = excludedIds.map(id => `'${id}'`).join(', ');
185
+ const onlyExcludedQuery = `
186
+ SELECT DISTINCT ec.entity_id as id
187
+ FROM entity_components ec
188
+ WHERE ${this.withId ? `ec.entity_id = '${this.withId}' AND ` : ''} NOT EXISTS (
189
+ SELECT 1 FROM entity_components ec_ex
190
+ WHERE ec_ex.entity_id = ec.entity_id AND ec_ex.type_id IN (${onlyExcludedIdsString})
191
+ )
192
+ `;
193
+ wrapLog(`Executing query: ${onlyExcludedQuery}`);
194
+ const onlyExcludedQueryResult = await db.unsafe(onlyExcludedQuery);
195
+ ids = onlyExcludedQueryResult.map((row: any) => row.id);
196
+ break;
197
+ default:
198
+ return [];
199
+ }
200
+
201
+ if (this.populateComponents) {
202
+ return await Entity.LoadMultiple(ids);
203
+ } else {
204
+ const len = ids.length;
205
+ const entities = new Array(len);
206
+ for (let i = 0; i < len; i += 4) {
207
+ if (i < len) {
208
+ const entity = new Entity(ids[i]);
209
+ entity.setPersisted(true);
210
+ entity.setDirty(false);
211
+ entities[i] = entity;
212
+ }
213
+ if (i + 1 < len) {
214
+ const entity = new Entity(ids[i + 1]);
215
+ entity.setPersisted(true);
216
+ entity.setDirty(false);
217
+ entities[i + 1] = entity;
218
+ }
219
+ if (i + 2 < len) {
220
+ const entity = new Entity(ids[i + 2]);
221
+ entity.setPersisted(true);
222
+ entity.setDirty(false);
223
+ entities[i + 2] = entity;
224
+ }
225
+ if (i + 3 < len) {
226
+ const entity = new Entity(ids[i + 3]);
227
+ entity.setPersisted(true);
228
+ entity.setDirty(false);
229
+ entities[i + 3] = entity;
230
+ }
231
+ }
232
+ return entities;
233
+ }
234
+ }
235
+
236
+ private async getIdsWithFilters(componentIds: string[], componentCount: number): Promise<string[]> {
237
+ let query = `
238
+ SELECT DISTINCT ec.entity_id as id
239
+ FROM entity_components ec
240
+ `;
241
+
242
+ const joins: string[] = [];
243
+ const whereConditions: string[] = [`ec.type_id IN (${componentIds.map(id => `'${id}'`).join(', ')})`];
244
+ if (this.withId) {
245
+ whereConditions.push(`ec.entity_id = '${this.withId}'`);
246
+ }
247
+
248
+ let joinIndex = 0;
249
+ for (const [typeId, filters] of this.componentFilters.entries()) {
250
+ if (componentIds.includes(typeId)) {
251
+ const alias = `c${joinIndex}`;
252
+ joins.push(`JOIN components ${alias} ON ec.entity_id = ${alias}.entity_id AND ${alias}.type_id = '${typeId}'`);
253
+
254
+ const filterCondition = this.buildFilterWhereClause(typeId, filters);
255
+ if (filterCondition) {
256
+ whereConditions.push(filterCondition.replace(/data->/g, `${alias}.data->`));
257
+ }
258
+ joinIndex++;
259
+ }
260
+ }
261
+
262
+ query += joins.join(' ');
263
+ query += ` WHERE ${whereConditions.join(' AND ')}`;
264
+ query += ` GROUP BY ec.entity_id HAVING COUNT(DISTINCT ec.type_id) = ${componentCount}`;
265
+
266
+ wrapLog(`Executing filtered query: ${query}`);
267
+ const filteredResult = await db.unsafe(query);
268
+ return filteredResult.map((row: any) => row.id);
269
+ }
270
+
271
+ private async getIdsWithFiltersAndExclusions(componentIds: string[], excludedIds: string[], componentCount: number): Promise<string[]> {
272
+ const entityIds = await this.getIdsWithFilters(componentIds, componentCount);
273
+
274
+ if (entityIds.length === 0) {
275
+ return [];
276
+ }
277
+
278
+ const idsString = entityIds.map(id => `'${id}'`).join(', ');
279
+ const excludedString = excludedIds.map(id => `'${id}'`).join(', ');
280
+ const query = `
281
+ WITH entity_list AS (
282
+ SELECT unnest(ARRAY[${idsString}]) as id
283
+ )
284
+ SELECT el.id
285
+ FROM entity_list el
286
+ WHERE NOT EXISTS (
287
+ SELECT 1 FROM entity_components ec
288
+ WHERE ec.entity_id = el.id AND ec.type_id IN (${excludedString})
289
+ )
290
+ `;
291
+ const exclusionResult = await db.unsafe(query);
292
+ return exclusionResult.map((row: any) => row.id);
293
+ }
294
+ }
295
+
296
+ export default Query;