bunsane 0.1.2 → 0.1.4
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/TODO.md +1 -1
- package/bun.lock +156 -150
- package/core/App.ts +188 -31
- package/core/ArcheType.ts +1044 -26
- package/core/ComponentRegistry.ts +172 -29
- package/core/Components.ts +102 -24
- package/core/Decorators.ts +0 -1
- package/core/Entity.ts +55 -7
- package/core/EntityInterface.ts +4 -0
- package/core/EntityManager.ts +4 -4
- package/core/Query.ts +169 -3
- package/core/RequestLoaders.ts +101 -12
- package/core/SchedulerManager.ts +3 -4
- package/core/metadata/definitions/ArcheType.ts +9 -0
- package/core/metadata/definitions/Component.ts +16 -0
- package/core/metadata/definitions/gqlObject.ts +10 -0
- package/core/metadata/getMetadataStorage.ts +14 -0
- package/core/metadata/index.ts +17 -0
- package/core/metadata/metadata-storage.ts +81 -0
- package/database/DatabaseHelper.ts +22 -20
- package/database/index.ts +6 -1
- package/database/sqlHelpers.ts +0 -2
- package/gql/ArchetypeOperations.ts +281 -0
- package/gql/Generator.ts +252 -62
- package/gql/helpers.ts +5 -5
- package/gql/index.ts +19 -17
- package/gql/types.ts +58 -11
- package/index.ts +93 -82
- package/package.json +39 -37
- package/plugins/index.ts +13 -0
- package/scheduler/index.ts +87 -0
- package/service/Service.ts +4 -0
- package/service/ServiceRegistry.ts +5 -1
- package/service/index.ts +1 -1
- package/swagger/decorators.ts +65 -0
- package/swagger/generator.ts +100 -0
- package/swagger/index.ts +2 -0
- package/tests/bench/insert.bench.ts +1 -0
- package/tests/bench/relations.bench.ts +1 -0
- package/tests/bench/sorting.bench.ts +1 -0
- package/tests/component-hooks-simple.test.ts +117 -0
- package/tests/component-hooks.test.ts +83 -31
- package/tests/component.test.ts +1 -0
- package/tests/hooks.test.ts +1 -0
- package/tests/query.test.ts +46 -4
- package/tests/relations.test.ts +1 -0
- package/types/app.types.ts +0 -0
- package/upload/index.ts +0 -2
- package/core/processors/ImageProcessor.ts +0 -423
package/core/Query.ts
CHANGED
|
@@ -7,7 +7,7 @@ import db from "database";
|
|
|
7
7
|
import { timed } from "./Decorators";
|
|
8
8
|
import { inList } from "../database/sqlHelpers";
|
|
9
9
|
|
|
10
|
-
export type FilterOperator = "=" | ">" | "<" | ">=" | "<=" | "!=" | "LIKE" | "IN" | "NOT IN";
|
|
10
|
+
export type FilterOperator = "=" | ">" | "<" | ">=" | "<=" | "!=" | "LIKE" | "IN" | "NOT IN" | string;
|
|
11
11
|
|
|
12
12
|
export const FilterOp = {
|
|
13
13
|
EQ: "=" as FilterOperator,
|
|
@@ -50,6 +50,12 @@ class Query {
|
|
|
50
50
|
private eagerComponents: Set<string> = new Set<string>();
|
|
51
51
|
private sortOrders: SortOrder[] = [];
|
|
52
52
|
|
|
53
|
+
private static customFilterBuilders: Map<string, (filter: QueryFilter, alias: string, paramIndex: number) => { sql: string, params: any[], newParamIndex: number }> = new Map();
|
|
54
|
+
|
|
55
|
+
public static registerFilterBuilder(operator: string, builder: (filter: QueryFilter, alias: string, paramIndex: number) => { sql: string, params: any[], newParamIndex: number }) {
|
|
56
|
+
this.customFilterBuilders.set(operator, builder);
|
|
57
|
+
}
|
|
58
|
+
|
|
53
59
|
static filterOp = FilterOp;
|
|
54
60
|
|
|
55
61
|
public findById(id: string) {
|
|
@@ -118,6 +124,11 @@ class Query {
|
|
|
118
124
|
private buildFilterCondition(filter: QueryFilter, alias: string, paramIndex: number): { sql: string, params: any[], newParamIndex: number } {
|
|
119
125
|
const { field, operator, value } = filter;
|
|
120
126
|
|
|
127
|
+
// Check for custom filter builders first
|
|
128
|
+
if (Query.customFilterBuilders.has(operator)) {
|
|
129
|
+
return Query.customFilterBuilders.get(operator)!(filter, alias, paramIndex);
|
|
130
|
+
}
|
|
131
|
+
|
|
121
132
|
// Build JSON path for nested properties (e.g., "parent.child" -> data->'parent'->>'child')
|
|
122
133
|
const jsonPath = this.buildJsonPath(field, alias);
|
|
123
134
|
|
|
@@ -258,6 +269,10 @@ class Query {
|
|
|
258
269
|
return this;
|
|
259
270
|
}
|
|
260
271
|
|
|
272
|
+
public count(): Promise<number> {
|
|
273
|
+
return this.doCount();
|
|
274
|
+
}
|
|
275
|
+
|
|
261
276
|
@timed("Query.exec")
|
|
262
277
|
public async exec(): Promise<Entity[]> {
|
|
263
278
|
return new Promise<Entity[]>((resolve, reject) => {
|
|
@@ -339,7 +354,6 @@ class Query {
|
|
|
339
354
|
let queryStr: any;
|
|
340
355
|
let requiredOnlyQueryResult: any;
|
|
341
356
|
if (componentCount === 1) {
|
|
342
|
-
// Phase 2A: Optimize single component sorting with JOIN
|
|
343
357
|
if (this.sortOrders.length > 0) {
|
|
344
358
|
const typeId = componentIds[0]!;
|
|
345
359
|
const sortExpression = this.buildSortExpressionForSingleComponent(typeId, "c");
|
|
@@ -679,7 +693,9 @@ class Query {
|
|
|
679
693
|
|
|
680
694
|
const filteredResult = await db.unsafe(sql, params);
|
|
681
695
|
return filteredResult.map((row: any) => row.id);
|
|
682
|
-
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private async getIdsWithFiltersAndExclusions(componentIds: string[], excludedIds: string[], componentCount: number, limit?: number | null, offset?: number): Promise<string[]> {
|
|
683
699
|
const entityIds = await this.getIdsWithFilters(componentIds, componentCount);
|
|
684
700
|
|
|
685
701
|
if (entityIds.length === 0) {
|
|
@@ -709,6 +725,156 @@ class Query {
|
|
|
709
725
|
const exclusionResult = await query;
|
|
710
726
|
return exclusionResult.map((row: any) => row.id);
|
|
711
727
|
}
|
|
728
|
+
|
|
729
|
+
private async doCount(): Promise<number> {
|
|
730
|
+
const componentIds = Array.from(this.requiredComponents);
|
|
731
|
+
const excludedIds = Array.from(this.excludedComponents);
|
|
732
|
+
const componentCount = componentIds.length;
|
|
733
|
+
const hasRequired = componentCount > 0;
|
|
734
|
+
const hasExcluded = excludedIds.length > 0;
|
|
735
|
+
const hasFilters = this.componentFilters.size > 0;
|
|
736
|
+
const hasWithId = this.withId !== null;
|
|
737
|
+
|
|
738
|
+
switch (true) {
|
|
739
|
+
case !hasRequired && !hasExcluded && !hasWithId:
|
|
740
|
+
return 0;
|
|
741
|
+
case !hasRequired && !hasExcluded && hasWithId:
|
|
742
|
+
const result = await db`SELECT COUNT(*) as count FROM entities WHERE id = ${this.withId} AND deleted_at IS NULL`;
|
|
743
|
+
return parseInt(result[0].count);
|
|
744
|
+
case hasRequired && hasExcluded && hasFilters:
|
|
745
|
+
return await this.getCountWithFiltersAndExclusions(componentIds, excludedIds, componentCount);
|
|
746
|
+
case hasRequired && hasExcluded:
|
|
747
|
+
const componentIdsString = inList(componentIds, 1);
|
|
748
|
+
const excludedIdsString = inList(excludedIds, componentIdsString.newParamIndex);
|
|
749
|
+
const excludedQuery = db`
|
|
750
|
+
SELECT COUNT(*) as count FROM (
|
|
751
|
+
SELECT ec.entity_id
|
|
752
|
+
FROM entity_components ec
|
|
753
|
+
WHERE ec.type_id IN ${db.unsafe(componentIdsString.sql, componentIdsString.params)} AND ec.deleted_at IS NULL
|
|
754
|
+
${this.withId ? db`AND ec.entity_id = ${this.withId}` : db``}
|
|
755
|
+
AND NOT EXISTS (
|
|
756
|
+
SELECT 1 FROM entity_components ec_ex
|
|
757
|
+
WHERE ec_ex.entity_id = ec.entity_id AND ec_ex.type_id IN ${db.unsafe(excludedIdsString.sql, excludedIdsString.params)} AND ec_ex.deleted_at IS NULL
|
|
758
|
+
)
|
|
759
|
+
GROUP BY ec.entity_id
|
|
760
|
+
HAVING COUNT(DISTINCT ec.type_id) = ${componentCount}
|
|
761
|
+
) as subquery
|
|
762
|
+
`;
|
|
763
|
+
const excludedResult = await excludedQuery;
|
|
764
|
+
return parseInt(excludedResult[0].count);
|
|
765
|
+
case hasRequired && hasFilters:
|
|
766
|
+
return await this.getCountWithFilters(componentIds, componentCount);
|
|
767
|
+
case hasRequired:
|
|
768
|
+
if (componentCount === 1) {
|
|
769
|
+
const countQuery = db`SELECT COUNT(*) as count FROM entity_components WHERE type_id = ${componentIds[0]} ${this.withId ? db`AND entity_id = ${this.withId}` : db``} AND deleted_at IS NULL`;
|
|
770
|
+
const countResult = await countQuery;
|
|
771
|
+
return parseInt(countResult[0].count);
|
|
772
|
+
} else {
|
|
773
|
+
const compIds = inList(componentIds, 1);
|
|
774
|
+
const multiComponentQuery = db`
|
|
775
|
+
SELECT COUNT(*) as count FROM (
|
|
776
|
+
SELECT entity_id FROM entity_components
|
|
777
|
+
WHERE type_id IN ${db.unsafe(compIds.sql, compIds.params)} ${this.withId ? db`AND entity_id = ${this.withId}` : db``} AND deleted_at IS NULL
|
|
778
|
+
GROUP BY entity_id
|
|
779
|
+
HAVING COUNT(DISTINCT type_id) = ${componentCount}
|
|
780
|
+
) as subquery
|
|
781
|
+
`;
|
|
782
|
+
const multiComponentResult = await multiComponentQuery;
|
|
783
|
+
return parseInt(multiComponentResult[0].count);
|
|
784
|
+
}
|
|
785
|
+
case hasExcluded:
|
|
786
|
+
const onlyExcludedIdsString = inList(excludedIds, 1);
|
|
787
|
+
const onlyExcludedQuery = db`
|
|
788
|
+
SELECT COUNT(*) as count FROM (
|
|
789
|
+
SELECT DISTINCT ec.entity_id
|
|
790
|
+
FROM entity_components ec
|
|
791
|
+
WHERE ${this.withId ? db`ec.entity_id = ${this.withId} AND ` : db``} NOT EXISTS (
|
|
792
|
+
SELECT 1 FROM entity_components ec_ex
|
|
793
|
+
WHERE ec_ex.entity_id = ec.entity_id AND ec_ex.type_id IN ${db.unsafe(onlyExcludedIdsString.sql, onlyExcludedIdsString.params)} AND ec_ex.deleted_at IS NULL
|
|
794
|
+
)
|
|
795
|
+
AND ec.deleted_at IS NULL
|
|
796
|
+
) as subquery
|
|
797
|
+
`;
|
|
798
|
+
const onlyExcludedResult = await onlyExcludedQuery;
|
|
799
|
+
return parseInt(onlyExcludedResult[0].count);
|
|
800
|
+
default:
|
|
801
|
+
return 0;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private async getCountWithFilters(componentIds: string[], componentCount: number): Promise<number> {
|
|
806
|
+
let params: any[] = [];
|
|
807
|
+
let paramIndex = 1;
|
|
808
|
+
const compIds = inList(componentIds, paramIndex);
|
|
809
|
+
params.push(...compIds.params);
|
|
810
|
+
paramIndex = compIds.newParamIndex;
|
|
811
|
+
|
|
812
|
+
const joins: string[] = [];
|
|
813
|
+
let joinIndex = 0;
|
|
814
|
+
for (const [typeId, filters] of this.componentFilters.entries()) {
|
|
815
|
+
if (componentIds.includes(typeId)) {
|
|
816
|
+
const alias = `c${joinIndex}`;
|
|
817
|
+
joins.push(`JOIN components ${alias} ON ec.entity_id = ${alias}.entity_id AND ${alias}.type_id = $${paramIndex} AND ${alias}.deleted_at IS NULL`);
|
|
818
|
+
params.push(typeId);
|
|
819
|
+
paramIndex++;
|
|
820
|
+
joinIndex++;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
let sql: string = `SELECT COUNT(*) as count FROM (SELECT DISTINCT ec.entity_id FROM entity_components ec ${joins.join(' ')} WHERE ec.type_id IN ${compIds.sql} AND ec.deleted_at IS NULL`;
|
|
825
|
+
|
|
826
|
+
if (this.withId) {
|
|
827
|
+
sql += ` AND ec.entity_id = $${paramIndex}`;
|
|
828
|
+
params.push(this.withId);
|
|
829
|
+
paramIndex++;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
joinIndex = 0;
|
|
833
|
+
for (const [typeId, filters] of this.componentFilters.entries()) {
|
|
834
|
+
if (componentIds.includes(typeId)) {
|
|
835
|
+
const alias = `c${joinIndex}`;
|
|
836
|
+
const filterConditions = this.buildFilterWhereClause(typeId, filters, alias, paramIndex);
|
|
837
|
+
if (filterConditions.sql) {
|
|
838
|
+
sql += ` AND ${filterConditions.sql}`;
|
|
839
|
+
params.push(...filterConditions.params);
|
|
840
|
+
paramIndex = filterConditions.newParamIndex;
|
|
841
|
+
}
|
|
842
|
+
joinIndex++;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
sql += ` GROUP BY ec.entity_id HAVING COUNT(DISTINCT ec.type_id) = $${paramIndex}) as subquery`;
|
|
847
|
+
params.push(componentCount);
|
|
848
|
+
|
|
849
|
+
const filteredResult = await db.unsafe(sql, params);
|
|
850
|
+
return parseInt(filteredResult[0].count);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
private async getCountWithFiltersAndExclusions(componentIds: string[], excludedIds: string[], componentCount: number): Promise<number> {
|
|
854
|
+
const entityIds = await this.getIdsWithFilters(componentIds, componentCount);
|
|
855
|
+
|
|
856
|
+
if (entityIds.length === 0) {
|
|
857
|
+
return 0;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const idsList = sql(entityIds);
|
|
861
|
+
const excludedList = inList(excludedIds, 1);
|
|
862
|
+
const query = db`
|
|
863
|
+
SELECT COUNT(*) as count FROM (
|
|
864
|
+
WITH entity_list AS (
|
|
865
|
+
SELECT unnest(${idsList}) as id
|
|
866
|
+
)
|
|
867
|
+
SELECT el.id
|
|
868
|
+
FROM entity_list el
|
|
869
|
+
WHERE NOT EXISTS (
|
|
870
|
+
SELECT 1 FROM entity_components ec
|
|
871
|
+
WHERE ec.entity_id = el.id AND ec.type_id IN ${db.unsafe(excludedList.sql, excludedList.params)} AND ec.deleted_at IS NULL
|
|
872
|
+
)
|
|
873
|
+
) as subquery
|
|
874
|
+
`;
|
|
875
|
+
const exclusionResult = await query;
|
|
876
|
+
return parseInt(exclusionResult[0].count);
|
|
877
|
+
}
|
|
712
878
|
}
|
|
713
879
|
|
|
714
880
|
export default Query;
|
package/core/RequestLoaders.ts
CHANGED
|
@@ -2,9 +2,12 @@ import DataLoader from 'dataloader';
|
|
|
2
2
|
import { Entity } from './Entity';
|
|
3
3
|
import db from '../database';
|
|
4
4
|
import { inList } from '../database/sqlHelpers';
|
|
5
|
+
import {logger as MainLogger} from './Logger';
|
|
6
|
+
const logger = MainLogger.child({ module: 'RequestLoaders' });
|
|
7
|
+
import { getMetadataStorage } from './metadata';
|
|
5
8
|
|
|
6
9
|
export type ComponentData = {
|
|
7
|
-
typeId:
|
|
10
|
+
typeId: string;
|
|
8
11
|
data: any;
|
|
9
12
|
createdAt: Date;
|
|
10
13
|
updatedAt: Date;
|
|
@@ -13,7 +16,8 @@ export type ComponentData = {
|
|
|
13
16
|
|
|
14
17
|
export type RequestLoaders = {
|
|
15
18
|
entityById: DataLoader<string, Entity | null>;
|
|
16
|
-
componentsByEntityType: DataLoader<{ entityId: string; typeId:
|
|
19
|
+
componentsByEntityType: DataLoader<{ entityId: string; typeId: string }, ComponentData | null>;
|
|
20
|
+
relationsByEntityField: DataLoader<{ entityId: string; relationField: string; relatedType: string; foreignKey?: string }, Entity[]>;
|
|
17
21
|
};
|
|
18
22
|
|
|
19
23
|
export function createRequestLoaders(db: any): RequestLoaders {
|
|
@@ -21,12 +25,13 @@ export function createRequestLoaders(db: any): RequestLoaders {
|
|
|
21
25
|
const startTime = Date.now();
|
|
22
26
|
try {
|
|
23
27
|
const uniqueIds = [...new Set(ids)];
|
|
24
|
-
const
|
|
28
|
+
const idList = inList(uniqueIds, 1);
|
|
29
|
+
const rows = await db.unsafe(`
|
|
25
30
|
SELECT id
|
|
26
31
|
FROM entities
|
|
27
|
-
WHERE id IN ${
|
|
32
|
+
WHERE id IN ${idList.sql}
|
|
28
33
|
AND deleted_at IS NULL
|
|
29
|
-
|
|
34
|
+
`, idList.params);
|
|
30
35
|
const entities = rows.map((row: any) => {
|
|
31
36
|
const entity = new Entity(row.id);
|
|
32
37
|
entity.setPersisted(true);
|
|
@@ -47,19 +52,21 @@ export function createRequestLoaders(db: any): RequestLoaders {
|
|
|
47
52
|
}
|
|
48
53
|
});
|
|
49
54
|
|
|
50
|
-
const componentsByEntityType = new DataLoader<{ entityId: string; typeId:
|
|
51
|
-
async (keys: readonly { entityId: string; typeId:
|
|
55
|
+
const componentsByEntityType = new DataLoader<{ entityId: string; typeId: string }, ComponentData | null>(
|
|
56
|
+
async (keys: readonly { entityId: string; typeId: string }[]) => {
|
|
52
57
|
const startTime = Date.now();
|
|
53
58
|
try {
|
|
54
59
|
const entityIds = [...new Set(keys.map(k => k.entityId))];
|
|
55
60
|
const typeIds = [...new Set(keys.map(k => k.typeId))];
|
|
56
|
-
const
|
|
61
|
+
const entityIdList = inList(entityIds, 1);
|
|
62
|
+
const typeIdList = inList(typeIds, entityIdList.newParamIndex);
|
|
63
|
+
const rows = await db.unsafe(`
|
|
57
64
|
SELECT entity_id, type_id, data, created_at, updated_at, deleted_at
|
|
58
65
|
FROM components
|
|
59
|
-
WHERE entity_id IN ${
|
|
60
|
-
AND type_id IN ${
|
|
66
|
+
WHERE entity_id IN ${entityIdList.sql}
|
|
67
|
+
AND type_id IN ${typeIdList.sql}
|
|
61
68
|
AND deleted_at IS NULL
|
|
62
|
-
|
|
69
|
+
`, [...entityIdList.params, ...typeIdList.params]);
|
|
63
70
|
const map = new Map<string, ComponentData>();
|
|
64
71
|
rows.forEach((row: any) => {
|
|
65
72
|
const key = `${row.entity_id}-${row.type_id}`;
|
|
@@ -85,5 +92,87 @@ export function createRequestLoaders(db: any): RequestLoaders {
|
|
|
85
92
|
}
|
|
86
93
|
);
|
|
87
94
|
|
|
88
|
-
|
|
95
|
+
const relationsByEntityField = new DataLoader<{ entityId: string; relationField: string; relatedType: string; foreignKey?: string }, Entity[]>(
|
|
96
|
+
async (keys: readonly { entityId: string; relationField: string; relatedType: string; foreignKey?: string }[]) => {
|
|
97
|
+
const startTime = Date.now();
|
|
98
|
+
try {
|
|
99
|
+
// Group keys by relation type for efficient querying
|
|
100
|
+
const resultMap = new Map<string, Entity[]>();
|
|
101
|
+
|
|
102
|
+
// For each key, find related entities based on foreign key relationships
|
|
103
|
+
for (const key of keys) {
|
|
104
|
+
let relatedEntities: Entity[] = [];
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
logger.trace(`[RelationLoader] Looking for ${key.relatedType} entities with foreign key ${key.foreignKey || 'auto-detect'} pointing to ${key.entityId} for field ${key.relationField}`);
|
|
108
|
+
|
|
109
|
+
let whereClause: string;
|
|
110
|
+
if (key.foreignKey) {
|
|
111
|
+
// Use specific foreign key from relation metadata
|
|
112
|
+
whereClause = `(c.data->>'${key.foreignKey}' = $1)`;
|
|
113
|
+
} else {
|
|
114
|
+
// Fallback to common patterns for backward compatibility
|
|
115
|
+
// TODO: Remove this fallback in future versions
|
|
116
|
+
whereClause = `(
|
|
117
|
+
(c.data->>'user_id' = $1) OR
|
|
118
|
+
(c.data->>'parent_id' = $1)
|
|
119
|
+
)`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Look for entities that have components with foreign keys pointing to our entity
|
|
123
|
+
const rows = await db.unsafe(`
|
|
124
|
+
SELECT DISTINCT c.entity_id, c.data, c.type_id
|
|
125
|
+
FROM components c
|
|
126
|
+
INNER JOIN entities e ON c.entity_id = e.id
|
|
127
|
+
WHERE e.deleted_at IS NULL
|
|
128
|
+
AND c.deleted_at IS NULL
|
|
129
|
+
AND ${whereClause}
|
|
130
|
+
`, [key.entityId]);
|
|
131
|
+
|
|
132
|
+
logger.trace(`[RelationLoader] Found ${rows.length} components with foreign keys pointing to ${key.entityId}`);
|
|
133
|
+
rows.forEach((row: any) => {
|
|
134
|
+
logger.trace(`[RelationLoader] Component ${row.type_id} on entity ${row.entity_id}:`, row.data);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Create Entity objects for each related entity
|
|
138
|
+
const entityIds = [...new Set(rows.map((row: any) => row.entity_id as string))];
|
|
139
|
+
relatedEntities = entityIds.map((id: string) => {
|
|
140
|
+
const entity = new Entity(id);
|
|
141
|
+
entity.setPersisted(true);
|
|
142
|
+
return entity;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
logger.trace(`[RelationLoader] Created ${relatedEntities.length} related entities for ${key.relationField}`);
|
|
146
|
+
|
|
147
|
+
} catch (queryError) {
|
|
148
|
+
logger.error(`Error querying relations for ${key.entityId}:`);
|
|
149
|
+
logger.error(queryError);
|
|
150
|
+
relatedEntities = [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const mapKey = `${key.entityId}-${key.relationField}-${key.relatedType}`;
|
|
154
|
+
resultMap.set(mapKey, relatedEntities);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const duration = Date.now() - startTime;
|
|
158
|
+
if (duration > 1000) {
|
|
159
|
+
logger.warn(`Slow relationsByEntityField query: ${duration}ms for ${keys.length} keys`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return keys.map(k => {
|
|
163
|
+
const mapKey = `${k.entityId}-${k.relationField}-${k.relatedType}`;
|
|
164
|
+
const result = resultMap.get(mapKey) || [];
|
|
165
|
+
logger.trace(`[RelationLoader] Returning ${result.length} entities for ${k.relationField} on ${k.entityId}`);
|
|
166
|
+
return result;
|
|
167
|
+
});
|
|
168
|
+
} catch (error) {
|
|
169
|
+
logger.error(`Error in relationsByEntityField DataLoader:`);
|
|
170
|
+
logger.error(error);
|
|
171
|
+
// Return empty arrays for all keys on error
|
|
172
|
+
return keys.map(() => []);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
return { entityById, componentsByEntityType, relationsByEntityField };
|
|
89
178
|
}
|
package/core/SchedulerManager.ts
CHANGED
|
@@ -685,10 +685,9 @@ export class SchedulerManager {
|
|
|
685
685
|
|
|
686
686
|
// Handle excluded components
|
|
687
687
|
if (componentTarget.excludeComponents && componentTarget.excludeComponents.length > 0) {
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
loggerInstance.warn('excludeComponents is not fully supported in scheduled tasks yet. Consider using post-query filtering.');
|
|
688
|
+
for(const component of componentTarget.excludeComponents){
|
|
689
|
+
query = query.without(component);
|
|
690
|
+
}
|
|
692
691
|
}
|
|
693
692
|
|
|
694
693
|
return query;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface ComponentMetadata {
|
|
2
|
+
name: string;
|
|
3
|
+
typeId: string;
|
|
4
|
+
target: Function;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ComponentPropertyMetadata {
|
|
8
|
+
propertyKey: string;
|
|
9
|
+
propertyType?: any;
|
|
10
|
+
component_id: string;
|
|
11
|
+
indexed: boolean;
|
|
12
|
+
isPrimitive: boolean;
|
|
13
|
+
isEnum: boolean;
|
|
14
|
+
enumValues?: string[];
|
|
15
|
+
enumKeys?: string[];
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { MetadataStorage } from "./metadata-storage";
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
// eslint-disable-next-line vars-on-top, no-var
|
|
5
|
+
var BunsaneMetadataStorage: MetadataStorage;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getMetadataStorage(): MetadataStorage {
|
|
9
|
+
if (!global.BunsaneMetadataStorage) {
|
|
10
|
+
global.BunsaneMetadataStorage = new MetadataStorage();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return global.BunsaneMetadataStorage;
|
|
14
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
export {getMetadataStorage} from "./getMetadataStorage";
|
|
3
|
+
export function Enum() {
|
|
4
|
+
return (target: any) => {
|
|
5
|
+
Reflect.defineMetadata("isEnum", true, target);
|
|
6
|
+
const staticKeys = Object.getOwnPropertyNames(target).filter(key =>
|
|
7
|
+
key !== 'prototype' &&
|
|
8
|
+
key !== 'length' &&
|
|
9
|
+
key !== 'name' &&
|
|
10
|
+
typeof target[key] !== 'function'
|
|
11
|
+
);
|
|
12
|
+
if (staticKeys.length > 0) {
|
|
13
|
+
Reflect.defineMetadata("__enumValues", staticKeys.map(key => target[key]), target);
|
|
14
|
+
Reflect.defineMetadata("__enumKeys", staticKeys, target);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import type {
|
|
3
|
+
ComponentMetadata,
|
|
4
|
+
ComponentPropertyMetadata
|
|
5
|
+
} from "./definitions/Component";
|
|
6
|
+
import type { ArcheTypeMetadata, ArcheTypeFieldOptions } from './definitions/ArcheType';
|
|
7
|
+
import type { RelationOptions } from '../ArcheType';
|
|
8
|
+
|
|
9
|
+
function generateTypeId(name: string): string {
|
|
10
|
+
return createHash('sha256').update(name).digest('hex');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type ArcheTypeRelationMap = {fieldName: string, relatedArcheType: new (...args: any[]) => any | string, relationType: 'hasMany' | 'belongsTo' | 'hasOne' | 'belongsToMany', options?: RelationOptions, type?: any}
|
|
14
|
+
type ArcheTypeFieldMap = {fieldName: string, component: new (...args: any[]) => any, options?: ArcheTypeFieldOptions, type?: any};
|
|
15
|
+
type ArcheTypeUnionMap = {fieldName: string, components: (new (...args:any[]) => any)[], options?: ArcheTypeFieldOptions, type?:any};
|
|
16
|
+
export class MetadataStorage {
|
|
17
|
+
components_ids_map: Map<string, string> = new Map();
|
|
18
|
+
components: ComponentMetadata[] = [];
|
|
19
|
+
components_map: Map<string, ComponentMetadata> = new Map();
|
|
20
|
+
componentProperties: Map<string, ComponentPropertyMetadata[]> = new Map();
|
|
21
|
+
archetypes: ArcheTypeMetadata[] = [];
|
|
22
|
+
archetypes_field_map: Map<string, ArcheTypeFieldMap[]> = new Map();
|
|
23
|
+
archetypes_relations_map: Map<string, ArcheTypeRelationMap[]> = new Map();
|
|
24
|
+
archetypes_union_map: Map<string, ArcheTypeUnionMap[]> = new Map();
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
graphql_types: Map<string, any> = new Map();
|
|
28
|
+
|
|
29
|
+
getComponentId(componentName: string): string {
|
|
30
|
+
if(this.components_ids_map.has(componentName)) {
|
|
31
|
+
return this.components_ids_map.get(componentName)!;
|
|
32
|
+
}
|
|
33
|
+
const typeId = generateTypeId(componentName);
|
|
34
|
+
this.components_ids_map.set(componentName, typeId);
|
|
35
|
+
return typeId;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
collectComponentMetadata(metadata: ComponentMetadata) {
|
|
39
|
+
this.components.push(metadata);
|
|
40
|
+
this.components_map.set(metadata.name, metadata);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
collectComponentPropertyMetadata(metadata: ComponentPropertyMetadata ) {
|
|
45
|
+
if(!this.componentProperties.has(metadata.component_id)) {
|
|
46
|
+
this.componentProperties.set(metadata.component_id, []);
|
|
47
|
+
}
|
|
48
|
+
this.componentProperties.get(metadata.component_id)!.push(metadata);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
getComponentProperties(component_id: string): ComponentPropertyMetadata[] {
|
|
53
|
+
return this.componentProperties.get(component_id) || [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
collectArchetypeField(archetype_id: string, fieldName: string, component: new (...args: any[]) => any, options?: ArcheTypeFieldOptions, type?: any) {
|
|
57
|
+
if(!this.archetypes_field_map.has(archetype_id)) {
|
|
58
|
+
this.archetypes_field_map.set(archetype_id, []);
|
|
59
|
+
}
|
|
60
|
+
this.archetypes_field_map.get(archetype_id)!.push({fieldName, component, options, type});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
collectArchetypeUnion(archetype_id: string, fieldName: string, components: (new (...args: any[]) => any)[], options?: ArcheTypeFieldOptions, type?: any) {
|
|
64
|
+
if(!this.archetypes_union_map.has(archetype_id)) {
|
|
65
|
+
this.archetypes_union_map.set(archetype_id, []);
|
|
66
|
+
}
|
|
67
|
+
this.archetypes_union_map.get(archetype_id)!.push({fieldName, components, options, type});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
collectArchetypeRelation(archetype_id: string, fieldName: string, relatedArcheType: new (...args: any[]) => any | string, relationType: 'hasMany' | 'belongsTo' | 'hasOne' | 'belongsToMany', options?: RelationOptions, type?: any) {
|
|
71
|
+
if(!this.archetypes_relations_map.has(archetype_id)) {
|
|
72
|
+
this.archetypes_relations_map.set(archetype_id, []);
|
|
73
|
+
}
|
|
74
|
+
this.archetypes_relations_map.get(archetype_id)!.push({fieldName, relatedArcheType, relationType, options, type});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
collectArcheTypeMetadata(metadata: ArcheTypeMetadata) {
|
|
78
|
+
this.archetypes.push(metadata);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
@@ -91,7 +91,6 @@ export const GetDatabaseDataSize = async () => {
|
|
|
91
91
|
|
|
92
92
|
|
|
93
93
|
export const SetupDatabaseExtensions = async () => {
|
|
94
|
-
// await db`CREATE EXTENSION IF NOT EXISTS "uuid-ossp";`;
|
|
95
94
|
}
|
|
96
95
|
|
|
97
96
|
export const CreateEntityTable = async () => {
|
|
@@ -173,16 +172,17 @@ export const UpdateComponentIndexes = async (table_name: string, indexedProperti
|
|
|
173
172
|
}
|
|
174
173
|
}
|
|
175
174
|
|
|
176
|
-
|
|
177
|
-
export const CreateComponentPartitionTable = async (comp_name: string, type_id: string
|
|
175
|
+
//TODO: Cleanup and optimize
|
|
176
|
+
export const CreateComponentPartitionTable = async (comp_name: string, type_id: string) => {
|
|
178
177
|
try {
|
|
179
178
|
comp_name = validateIdentifier(comp_name);
|
|
180
|
-
// type_id is a value, not identifier, so no validation
|
|
181
|
-
if (indexedProperties) {
|
|
182
|
-
|
|
183
|
-
}
|
|
179
|
+
// // type_id is a value, not identifier, so no validation
|
|
180
|
+
// if (indexedProperties) {
|
|
181
|
+
// indexedProperties = indexedProperties.map(prop => validateIdentifier(prop));
|
|
182
|
+
// }
|
|
184
183
|
logger.trace(`Attempt adding partition table for component: ${comp_name}`);
|
|
185
|
-
const table_name = `components_${comp_name.toLowerCase().replace(/\s+/g, '_')}`;
|
|
184
|
+
// const table_name = `components_${comp_name.toLowerCase().replace(/\s+/g, '_')}`;
|
|
185
|
+
const table_name = GenerateTableName(comp_name);
|
|
186
186
|
logger.trace(`Checking for existing partition table: ${table_name}`);
|
|
187
187
|
const existingPartition = await db.unsafe(`SELECT 1 FROM information_schema.tables
|
|
188
188
|
WHERE table_name = '${table_name}'
|
|
@@ -202,16 +202,17 @@ export const CreateComponentPartitionTable = async (comp_name: string, type_id:
|
|
|
202
202
|
});
|
|
203
203
|
logger.trace(`Successfully created partition table: ${table_name}`);
|
|
204
204
|
|
|
205
|
-
if
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
205
|
+
// TODO: Not sure if this is needed here or should be handled separately
|
|
206
|
+
// if (BUNSANE_RELATION_TYPED_COLUMN && indexedProperties?.includes('value')) {
|
|
207
|
+
// logger.trace(`Adding typed FK column for ${table_name}`);
|
|
208
|
+
// await retryWithBackoff(async () => {
|
|
209
|
+
// await db.unsafe(`ALTER TABLE ${table_name} ADD COLUMN IF NOT EXISTS fk_id UUID GENERATED ALWAYS AS ((data->>'value')::UUID) STORED`);
|
|
210
|
+
// });
|
|
211
|
+
// await retryWithBackoff(async () => {
|
|
212
|
+
// await db.unsafe(`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_${table_name}_fk_id ON ${table_name} (fk_id)`);
|
|
213
|
+
// });
|
|
214
|
+
// logger.trace(`Added fk_id column and index for ${table_name}`);
|
|
215
|
+
// }
|
|
215
216
|
|
|
216
217
|
} catch (error) {
|
|
217
218
|
logger.error(`Failed to create component partition table for ${comp_name}: ${error}`);
|
|
@@ -257,7 +258,8 @@ export const CreateEntityComponentTable = async () => {
|
|
|
257
258
|
await db`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_entity_components_type_id ON entity_components (type_id);`
|
|
258
259
|
await db`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_entity_components_type_entity ON entity_components (type_id, entity_id);`
|
|
259
260
|
|
|
260
|
-
// Phase 2A: Add composite indexes for sorting optimization
|
|
261
261
|
await db`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_entity_components_type_entity_deleted ON entity_components (type_id, entity_id, deleted_at);`
|
|
262
262
|
await db`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_entity_components_deleted_type ON entity_components (deleted_at, type_id) WHERE deleted_at IS NULL;`
|
|
263
|
-
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export const GenerateTableName = (name: string) => `components_${name.toLowerCase().replace(/\s+/g, '_')}`;
|
package/database/index.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import {SQL} from "bun";
|
|
2
2
|
import { logger } from "core/Logger";
|
|
3
3
|
|
|
4
|
+
let connectionUrl = `postgres://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST}:${process.env.POSTGRES_PORT ?? "5432"}/${process.env.POSTGRES_DB}`;
|
|
5
|
+
if(process.env.DB_CONNECTION_URL) {
|
|
6
|
+
connectionUrl = process.env.DB_CONNECTION_URL;
|
|
7
|
+
}
|
|
8
|
+
logger.info(`Database connection URL: ${connectionUrl}`);
|
|
4
9
|
const db = new SQL({
|
|
5
|
-
url:
|
|
10
|
+
url: connectionUrl,
|
|
6
11
|
// Connection pool settings - FIXED
|
|
7
12
|
max: parseInt(process.env.POSTGRES_MAX_CONNECTIONS ?? '20', 10), // Increased max connections
|
|
8
13
|
idleTimeout: 30000, // Close idle connections after 30s (was 0)
|
package/database/sqlHelpers.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { sql } from 'bun';
|
|
2
|
-
|
|
3
1
|
export function inList<T>(values: T[], paramIndex: number): { sql: string, params: any[], newParamIndex: number } {
|
|
4
2
|
if (values.length === 0) return { sql: '()', params: [], newParamIndex: paramIndex };
|
|
5
3
|
const placeholders = Array.from({length: values.length}, (_, i) => `$${paramIndex + i}`).join(', ');
|