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
package/core/Components.ts
CHANGED
|
@@ -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
|
}
|
package/core/Decorators.ts
CHANGED
|
@@ -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(
|
|
11
|
-
|
|
11
|
+
export function timed(hint?: string) {
|
|
12
|
+
return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
|
13
|
+
const originalMethod = descriptor.value;
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
+
}
|