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.
Files changed (85) 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 +119 -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 +159 -12
  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 +453 -85
  20. package/core/RequestContext.ts +24 -0
  21. package/core/RequestLoaders.ts +65 -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 +1 -1
  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/examples/hooks/README.md +228 -0
  52. package/examples/hooks/audit-logger.ts +495 -0
  53. package/gql/Generator.ts +56 -34
  54. package/gql/decorators/Upload.ts +176 -0
  55. package/gql/helpers.ts +67 -0
  56. package/gql/index.ts +55 -31
  57. package/gql/types.ts +1 -1
  58. package/index.ts +79 -11
  59. package/package.json +5 -4
  60. package/rest/Generator.ts +3 -0
  61. package/rest/index.ts +22 -0
  62. package/service/Service.ts +1 -1
  63. package/service/ServiceRegistry.ts +10 -6
  64. package/service/index.ts +12 -1
  65. package/tests/bench/insert.bench.ts +59 -0
  66. package/tests/bench/relations.bench.ts +269 -0
  67. package/tests/bench/sorting.bench.ts +415 -0
  68. package/tests/component-hooks.test.ts +1409 -0
  69. package/tests/component.test.ts +205 -0
  70. package/tests/errorHandling.test.ts +155 -0
  71. package/tests/hooks.test.ts +666 -0
  72. package/tests/query-sorting.test.ts +101 -0
  73. package/tests/relations.test.ts +169 -0
  74. package/tests/scheduler.test.ts +724 -0
  75. package/tsconfig.json +35 -34
  76. package/types/graphql.types.ts +87 -0
  77. package/types/hooks.types.ts +141 -0
  78. package/types/scheduler.types.ts +165 -0
  79. package/types/upload.types.ts +184 -0
  80. package/upload/index.ts +140 -0
  81. package/utils/UploadHelper.ts +305 -0
  82. package/utils/cronParser.ts +366 -0
  83. package/utils/errorMessages.ts +151 -0
  84. package/validate-docs.sh +90 -0
  85. package/core/Events.ts +0 -0
@@ -45,7 +45,7 @@ export class BaseComponent {
45
45
 
46
46
  properties(): string[] {
47
47
  return Object.keys(this).filter(prop => {
48
- const meta = Reflect.getMetadata("compData", this, prop);
48
+ const meta = Reflect.getMetadata("compData", Object.getPrototypeOf(this), prop);
49
49
  return meta && meta.isData;
50
50
  });
51
51
  }
@@ -113,7 +113,7 @@ export class BaseComponent {
113
113
 
114
114
  indexedProperties(): string[] {
115
115
  return Object.keys(this).filter(prop => {
116
- const meta = Reflect.getMetadata("compData", this, prop);
116
+ const meta = Reflect.getMetadata("compData", Object.getPrototypeOf(this), prop);
117
117
  return meta && meta.isData && meta.indexed;
118
118
  });
119
119
  }
@@ -1,3 +1,4 @@
1
+ import { logger } from "./Logger";
1
2
  export function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
2
3
  const originalMethod = descriptor.value;
3
4
 
@@ -7,14 +8,20 @@ export function log(target: any, propertyKey: string, descriptor: PropertyDescri
7
8
  };
8
9
  }
9
10
 
10
- export function timed(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
11
- const originalMethod = descriptor.value;
11
+ export function timed(hint?: string) {
12
+ return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
13
+ const originalMethod = descriptor.value;
12
14
 
13
- descriptor.value = async function(...args: any[]) {
14
- const start = performance.now();
15
- const result = await originalMethod.apply(this, args);
16
- const end = performance.now();
17
- console.log(`Execution time for ${propertyKey}: ${end - start} ms`);
18
- return result;
15
+ descriptor.value = async function(...args: any[]) {
16
+ if(process.env.NODE_ENV !== 'production') {
17
+ const start = performance.now();
18
+ const result = await originalMethod.apply(this, args);
19
+ const end = performance.now();
20
+ logger.trace(`Execution time for ${propertyKey}${hint ? ` (${hint})` : ''}: ${end - start} ms`);
21
+ return result;
22
+ } else {
23
+ return await originalMethod.apply(this, args);
24
+ }
25
+ };
19
26
  };
20
27
  }
package/core/Entity.ts CHANGED
@@ -6,7 +6,9 @@ import ComponentRegistry from "./ComponentRegistry";
6
6
  import { uuidv7 } from "utils/uuid";
7
7
  import { sql } from "bun";
8
8
  import Query from "./Query";
9
-
9
+ import { timed } from "./Decorators";
10
+ import EntityHookManager from "./EntityHookManager";
11
+ import { EntityCreatedEvent, EntityUpdatedEvent, EntityDeletedEvent, ComponentAddedEvent, ComponentUpdatedEvent, ComponentRemovedEvent } from "./events/EntityLifecycleEvents";
10
12
 
11
13
  export class Entity {
12
14
  id: string;
@@ -23,7 +25,7 @@ export class Entity {
23
25
  return new Entity();
24
26
  }
25
27
 
26
- private addComponent(component: BaseComponent): Entity {
28
+ protected addComponent(component: BaseComponent): Entity {
27
29
  this.components.set(component.getTypeID(), component);
28
30
  return this;
29
31
  }
@@ -36,10 +38,19 @@ export class Entity {
36
38
  * Adds a new component to the entity.
37
39
  * Use like: entity.add(Component, { value: "Test" })
38
40
  */
39
- public add<T extends BaseComponent>(ctor: new (...args: any[]) => T, data: ComponentDataType<T>): this {
41
+ public add<T extends BaseComponent>(ctor: new (...args: any[]) => T, data: Partial<ComponentDataType<T>>): this {
40
42
  const instance = new ctor();
41
43
  Object.assign(instance, data);
42
44
  this.addComponent(instance);
45
+
46
+ // Fire component added event
47
+ try {
48
+ EntityHookManager.executeHooks(new ComponentAddedEvent(this, instance));
49
+ } catch (error) {
50
+ logger.error(`Error firing component added hook for ${instance.getTypeID()}: ${error}`);
51
+ // Don't fail the add operation if hooks fail
52
+ }
53
+
43
54
  return this;
44
55
  }
45
56
 
@@ -49,24 +60,59 @@ export class Entity {
49
60
  * If it doesn't exist, it adds a new component.
50
61
  * Use like: entity.set(Component, { value: "Test" })
51
62
  */
52
- public async set<T extends BaseComponent>(ctor: new (...args: any[]) => T, data: Record<string, any>): Promise<this> {
63
+ public async set<T extends BaseComponent>(ctor: new (...args: any[]) => T, data: Partial<ComponentDataType<T>>): Promise<this> {
53
64
  await this.get(ctor);
54
65
 
55
66
  const component = Array.from(this.components.values()).find(comp => comp instanceof ctor) as T;
56
67
  if (component) {
57
- console.log("Updating Existing Component", component.getTypeID())
68
+ // Store old data for the update event
69
+ const oldData = { ...component };
70
+
58
71
  // Update existing component
59
72
  Object.assign(component, data);
60
73
  component.setDirty(true);
61
74
  this._dirty = true;
75
+
76
+ // Fire component updated event
77
+ try {
78
+ EntityHookManager.executeHooks(new ComponentUpdatedEvent(this, component, oldData, component));
79
+ } catch (error) {
80
+ logger.error(`Error firing component updated hook for ${component.getTypeID()}: ${error}`);
81
+ // Don't fail the set operation if hooks fail
82
+ }
62
83
  } else {
63
84
  // Add new component
64
- console.log("Adding New Component")
65
- this.add(ctor, data as any);
66
- this._dirty = true;
85
+ this.add(ctor, data);
86
+ // Note: add() already fires ComponentAddedEvent, so we don't need to fire it again
67
87
  }
68
88
  return this;
69
89
  }
90
+
91
+ /**
92
+ * Removes a component from the entity.
93
+ * Use like: entity.remove(Component)
94
+ */
95
+ public remove<T extends BaseComponent>(ctor: new (...args: any[]) => T): boolean {
96
+ const component = Array.from(this.components.values()).find(comp => comp instanceof ctor) as T;
97
+
98
+ if (component) {
99
+ // Remove the component from the map
100
+ this.components.delete(component.getTypeID());
101
+ this._dirty = true;
102
+
103
+ // Fire component removed event
104
+ try {
105
+ EntityHookManager.executeHooks(new ComponentRemovedEvent(this, component));
106
+ } catch (error) {
107
+ logger.error(`Error firing component removed hook for ${component.getTypeID()}: ${error}`);
108
+ // Don't fail the remove operation if hooks fail
109
+ }
110
+
111
+ return true;
112
+ }
113
+
114
+ return false;
115
+ }
70
116
  /**
71
117
  * Get component from entities. If entity is populated in query the component will get within the entitiy
72
118
  * If not it will fetch from database
@@ -82,7 +128,7 @@ export class Entity {
82
128
  const temp = new ctor();
83
129
  const typeId = temp.getTypeID();
84
130
  try {
85
- const rows = await db`SELECT id, data FROM components WHERE entity_id = ${this.id} AND type_id = ${typeId}`;
131
+ const rows = await db`SELECT id, data FROM components WHERE entity_id = ${this.id} AND type_id = ${typeId} AND deleted_at IS NULL`;
86
132
  if (rows.length > 0) {
87
133
  const row = rows[0];
88
134
  const comp = new ctor();
@@ -102,16 +148,23 @@ export class Entity {
102
148
  }
103
149
  }
104
150
 
151
+ @timed("Entity.save")
105
152
  public save() {
106
153
  return EntityManager.saveEntity(this);
107
154
  }
108
155
 
156
+
157
+
109
158
  public doSave() {
110
159
  return new Promise<boolean>(async resolve => {
111
160
  if(!this._dirty) {
112
161
  console.log("Entity is not dirty, no need to save.");
113
162
  return resolve(true);
114
163
  }
164
+
165
+ const wasNew = !this._persisted;
166
+ const changedComponents = this.getDirtyComponents();
167
+
115
168
  await db.transaction(async (trx) => {
116
169
  if(!this._persisted) {
117
170
  await trx`INSERT INTO entities (id) VALUES (${this.id}) ON CONFLICT DO NOTHING`;
@@ -122,19 +175,70 @@ export class Entity {
122
175
  return;
123
176
  }
124
177
  const waitable = [];
125
-
126
178
  for(const comp of this.components.values()) {
127
179
  waitable.push(comp.save(trx, this.id));
128
180
  }
129
-
130
181
  await Promise.all(waitable);
131
182
  });
183
+
132
184
  this._dirty = false;
185
+
186
+ // Fire lifecycle events after successful save
187
+ try {
188
+ if (wasNew) {
189
+ await EntityHookManager.executeHooks(new EntityCreatedEvent(this));
190
+ } else if (changedComponents.length > 0) {
191
+ await EntityHookManager.executeHooks(new EntityUpdatedEvent(this, changedComponents));
192
+ }
193
+ } catch (error) {
194
+ logger.error(`Error firing lifecycle hooks for entity ${this.id}: ${error}`);
195
+ // Don't fail the save operation if hooks fail
196
+ }
197
+
133
198
  resolve(true);
134
199
  })
135
200
 
136
201
  }
137
202
 
203
+ public delete(force: boolean = false) {
204
+ return EntityManager.deleteEntity(this, force);
205
+ }
206
+
207
+ public doDelete(force: boolean = false) {
208
+ return new Promise<boolean>(async resolve => {
209
+ if(!this._persisted) {
210
+ console.log("Entity is not persisted, cannot delete.");
211
+ return resolve(false);
212
+ }
213
+ try {
214
+ await db.transaction(async (trx) => {
215
+ if(force) {
216
+ await trx`DELETE FROM entity_components WHERE entity_id = ${this.id}`;
217
+ await trx`DELETE FROM components WHERE entity_id = ${this.id}`;
218
+ await trx`DELETE FROM entities WHERE id = ${this.id}`;
219
+ } else {
220
+ await trx`UPDATE entities SET deleted_at = CURRENT_TIMESTAMP WHERE id = ${this.id} AND deleted_at IS NULL`;
221
+ await trx`UPDATE entity_components SET deleted_at = CURRENT_TIMESTAMP WHERE entity_id = ${this.id} AND deleted_at IS NULL`;
222
+ await trx`UPDATE components SET deleted_at = CURRENT_TIMESTAMP WHERE entity_id = ${this.id} AND deleted_at IS NULL`;
223
+ }
224
+ });
225
+
226
+ // Fire lifecycle event after successful deletion
227
+ try {
228
+ await EntityHookManager.executeHooks(new EntityDeletedEvent(this, !force));
229
+ } catch (error) {
230
+ logger.error(`Error firing delete lifecycle hook for entity ${this.id}: ${error}`);
231
+ // Don't fail the delete operation if hooks fail
232
+ }
233
+
234
+ resolve(true);
235
+ } catch (error) {
236
+ logger.error(`Failed to delete entity: ${error}`);
237
+ resolve(false);
238
+ }
239
+ })
240
+ }
241
+
138
242
  public setPersisted(persisted: boolean) {
139
243
  this._persisted = persisted;
140
244
  }
@@ -143,13 +247,28 @@ export class Entity {
143
247
  this._dirty = dirty;
144
248
  }
145
249
 
250
+ /**
251
+ * Get list of component type IDs that are dirty
252
+ */
253
+ private getDirtyComponents(): string[] {
254
+ const dirtyComponents: string[] = [];
255
+ for (const component of this.components.values()) {
256
+ if ((component as any)._dirty) {
257
+ dirtyComponents.push(component.getTypeID());
258
+ }
259
+ }
260
+ return dirtyComponents;
261
+ }
262
+
263
+
264
+ @timed("Entity.LoadMultiple")
146
265
  public static async LoadMultiple(ids: string[]): Promise<Entity[]> {
147
266
  if (ids.length === 0) return [];
148
267
 
149
268
  const components = await db`
150
269
  SELECT c.id, c.entity_id, c.type_id, c.data
151
270
  FROM components c
152
- WHERE c.entity_id IN ${sql(ids)}
271
+ WHERE c.entity_id IN ${sql(ids)} AND c.deleted_at IS NULL
153
272
  `;
154
273
 
155
274
  const entitiesMap = new Map<string, Entity>();
@@ -178,6 +297,34 @@ export class Entity {
178
297
  return Array.from(entitiesMap.values());
179
298
  }
180
299
 
300
+ public static async LoadComponents(entities: Entity[], componentIds: string[]): Promise<void> {
301
+ if (entities.length === 0 || componentIds.length === 0) return;
302
+
303
+ const entityIds = entities.map(e => e.id);
304
+
305
+ const components = await db`
306
+ SELECT c.id, c.entity_id, c.type_id, c.data
307
+ FROM components c
308
+ WHERE c.entity_id IN ${sql(entityIds)} AND c.type_id IN ${sql(componentIds)} AND c.deleted_at IS NULL
309
+ `;
310
+
311
+ for (const row of components) {
312
+ const { id, entity_id, type_id, data } = row;
313
+ const entity = entities.find(e => e.id === entity_id);
314
+ if (entity) {
315
+ const ctor = ComponentRegistry.getConstructor(type_id);
316
+ if (ctor) {
317
+ const comp = new ctor();
318
+ Object.assign(comp, data);
319
+ comp.id = id;
320
+ comp.setPersisted(true);
321
+ comp.setDirty(false);
322
+ entity.addComponent(comp);
323
+ }
324
+ }
325
+ }
326
+ }
327
+
181
328
  public static async FindById(id: string): Promise<Entity | null> {
182
329
  const entities = await new Query().findById(id).populate().exec()
183
330
  if(entities.length === 1) {
@@ -0,0 +1,15 @@
1
+ import type { RequestLoaders, ComponentData } from './RequestLoaders';
2
+ import { Entity } from './Entity';
3
+
4
+ export async function getEntityById(ctx: { locals: { loaders: RequestLoaders } }, id: string): Promise<Entity | null> {
5
+ return ctx.locals.loaders.entityById.load(id);
6
+ }
7
+
8
+ export async function getComponent(ctx: { locals: { loaders: RequestLoaders } }, entityId: string, typeId: number): Promise<ComponentData | null> {
9
+ return ctx.locals.loaders.componentsByEntityType.load({ entityId, typeId });
10
+ }
11
+
12
+ export async function preloadComponents(ctx: { locals: { loaders: RequestLoaders } }, entityIds: string[], typeId: number): Promise<void> {
13
+ const keys = entityIds.map(entityId => ({ entityId, typeId }));
14
+ await ctx.locals.loaders.componentsByEntityType.loadMany(keys);
15
+ }