bunsane 0.1.4 → 0.2.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.
Files changed (257) hide show
  1. package/.claude/settings.local.json +47 -0
  2. package/.claude/skills/update-memory.md +74 -0
  3. package/.prettierrc +4 -0
  4. package/.serena/memories/architectural-decision-no-dependency-injection.md +76 -0
  5. package/.serena/memories/architecture.md +154 -0
  6. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +165 -0
  7. package/.serena/memories/code_style_and_conventions.md +76 -0
  8. package/.serena/memories/project_overview.md +43 -0
  9. package/.serena/memories/schema-dsl-plan.md +107 -0
  10. package/.serena/memories/suggested_commands.md +80 -0
  11. package/.serena/memories/typescript-compilation-status.md +54 -0
  12. package/.serena/project.yml +114 -0
  13. package/TODO.md +1 -7
  14. package/bun.lock +150 -4
  15. package/bunfig.toml +10 -0
  16. package/config/cache.config.ts +77 -0
  17. package/config/upload.config.ts +4 -5
  18. package/core/App.ts +870 -123
  19. package/core/ArcheType.ts +2268 -377
  20. package/core/BatchLoader.ts +181 -71
  21. package/core/Config.ts +153 -0
  22. package/core/Decorators.ts +4 -1
  23. package/core/Entity.ts +621 -92
  24. package/core/EntityHookManager.ts +1 -1
  25. package/core/EntityInterface.ts +3 -1
  26. package/core/EntityManager.ts +1 -13
  27. package/core/ErrorHandler.ts +8 -2
  28. package/core/Logger.ts +9 -0
  29. package/core/Middleware.ts +34 -0
  30. package/core/RequestContext.ts +5 -1
  31. package/core/RequestLoaders.ts +227 -93
  32. package/core/SchedulerManager.ts +193 -52
  33. package/core/cache/CacheAnalytics.ts +399 -0
  34. package/core/cache/CacheFactory.ts +145 -0
  35. package/core/cache/CacheManager.ts +520 -0
  36. package/core/cache/CacheProvider.ts +34 -0
  37. package/core/cache/CacheWarmer.ts +157 -0
  38. package/core/cache/CompressionUtils.ts +110 -0
  39. package/core/cache/MemoryCache.ts +251 -0
  40. package/core/cache/MultiLevelCache.ts +180 -0
  41. package/core/cache/NoOpCache.ts +53 -0
  42. package/core/cache/RedisCache.ts +464 -0
  43. package/core/cache/TTLStrategy.ts +254 -0
  44. package/core/cache/index.ts +6 -0
  45. package/core/components/BaseComponent.ts +120 -0
  46. package/core/{ComponentRegistry.ts → components/ComponentRegistry.ts} +148 -54
  47. package/core/components/Decorators.ts +88 -0
  48. package/core/components/Interfaces.ts +7 -0
  49. package/core/components/index.ts +5 -0
  50. package/core/decorators/EntityHooks.ts +0 -3
  51. package/core/decorators/IndexedField.ts +26 -0
  52. package/core/decorators/ScheduledTask.ts +0 -47
  53. package/core/events/EntityLifecycleEvents.ts +1 -1
  54. package/core/health.ts +112 -0
  55. package/core/metadata/definitions/ArcheType.ts +14 -0
  56. package/core/metadata/definitions/Component.ts +9 -0
  57. package/core/metadata/definitions/gqlObject.ts +1 -1
  58. package/core/metadata/index.ts +42 -1
  59. package/core/metadata/metadata-storage.ts +28 -2
  60. package/core/middleware/AccessLog.ts +59 -0
  61. package/core/middleware/RequestId.ts +38 -0
  62. package/core/middleware/SecurityHeaders.ts +62 -0
  63. package/core/middleware/index.ts +3 -0
  64. package/core/scheduler/DistributedLock.ts +266 -0
  65. package/core/scheduler/index.ts +15 -0
  66. package/core/validateEnv.ts +92 -0
  67. package/database/DatabaseHelper.ts +416 -40
  68. package/database/IndexingStrategy.ts +342 -0
  69. package/database/PreparedStatementCache.ts +226 -0
  70. package/database/index.ts +32 -7
  71. package/database/sqlHelpers.ts +14 -2
  72. package/endpoints/archetypes.ts +362 -0
  73. package/endpoints/components.ts +58 -0
  74. package/endpoints/entity.ts +80 -0
  75. package/endpoints/index.ts +27 -0
  76. package/endpoints/query.ts +93 -0
  77. package/endpoints/stats.ts +76 -0
  78. package/endpoints/tables.ts +212 -0
  79. package/endpoints/types.ts +155 -0
  80. package/gql/ArchetypeOperations.ts +32 -86
  81. package/gql/Generator.ts +27 -315
  82. package/gql/GeneratorV2.ts +37 -0
  83. package/gql/builders/InputTypeBuilder.ts +99 -0
  84. package/gql/builders/ResolverBuilder.ts +234 -0
  85. package/gql/builders/TypeDefBuilder.ts +105 -0
  86. package/gql/builders/index.ts +3 -0
  87. package/gql/decorators/Upload.ts +1 -1
  88. package/gql/depthLimit.ts +85 -0
  89. package/gql/graph/GraphNode.ts +224 -0
  90. package/gql/graph/SchemaGraph.ts +278 -0
  91. package/gql/helpers.ts +8 -2
  92. package/gql/index.ts +56 -4
  93. package/gql/middleware.ts +79 -0
  94. package/gql/orchestration/GraphQLSchemaOrchestrator.ts +241 -0
  95. package/gql/orchestration/index.ts +1 -0
  96. package/gql/scanner/ServiceScanner.ts +347 -0
  97. package/gql/schema/index.ts +458 -0
  98. package/gql/strategies/TypeGenerationStrategy.ts +329 -0
  99. package/gql/types.ts +1 -0
  100. package/gql/utils/TypeSignature.ts +220 -0
  101. package/gql/utils/index.ts +1 -0
  102. package/gql/visitors/ArchetypePreprocessorVisitor.ts +80 -0
  103. package/gql/visitors/DeduplicationVisitor.ts +82 -0
  104. package/gql/visitors/GraphVisitor.ts +78 -0
  105. package/gql/visitors/ResolverGeneratorVisitor.ts +122 -0
  106. package/gql/visitors/SchemaGeneratorVisitor.ts +851 -0
  107. package/gql/visitors/TypeCollectorVisitor.ts +79 -0
  108. package/gql/visitors/VisitorComposer.ts +96 -0
  109. package/gql/visitors/index.ts +7 -0
  110. package/package.json +59 -37
  111. package/plugins/index.ts +2 -2
  112. package/query/CTENode.ts +97 -0
  113. package/query/ComponentInclusionNode.ts +689 -0
  114. package/query/FilterBuilder.ts +127 -0
  115. package/query/FilterBuilderRegistry.ts +202 -0
  116. package/query/OrNode.ts +517 -0
  117. package/query/OrQuery.ts +42 -0
  118. package/query/Query.ts +1022 -0
  119. package/query/QueryContext.ts +170 -0
  120. package/query/QueryDAG.ts +122 -0
  121. package/query/QueryNode.ts +65 -0
  122. package/query/SourceNode.ts +53 -0
  123. package/query/builders/FullTextSearchBuilder.ts +236 -0
  124. package/query/index.ts +21 -0
  125. package/scheduler/index.ts +40 -8
  126. package/service/Service.ts +2 -1
  127. package/service/ServiceRegistry.ts +6 -5
  128. package/{core/storage → storage}/LocalStorageProvider.ts +2 -2
  129. package/storage/S3StorageProvider.ts +316 -0
  130. package/{core/storage → storage}/StorageProvider.ts +7 -3
  131. package/studio/bun.lock +482 -0
  132. package/studio/index.html +13 -0
  133. package/studio/package.json +39 -0
  134. package/studio/postcss.config.js +6 -0
  135. package/studio/src/components/DataTable.tsx +211 -0
  136. package/studio/src/components/Layout.tsx +13 -0
  137. package/studio/src/components/PageContainer.tsx +9 -0
  138. package/studio/src/components/PageHeader.tsx +13 -0
  139. package/studio/src/components/SearchBar.tsx +57 -0
  140. package/studio/src/components/Sidebar.tsx +294 -0
  141. package/studio/src/components/ui/button.tsx +56 -0
  142. package/studio/src/components/ui/checkbox.tsx +26 -0
  143. package/studio/src/components/ui/input.tsx +25 -0
  144. package/studio/src/hooks/useDataTable.ts +131 -0
  145. package/studio/src/index.css +36 -0
  146. package/studio/src/lib/api.ts +186 -0
  147. package/studio/src/lib/utils.ts +13 -0
  148. package/studio/src/main.tsx +17 -0
  149. package/studio/src/pages/ArcheType.tsx +239 -0
  150. package/studio/src/pages/Components.tsx +124 -0
  151. package/studio/src/pages/EntityInspector.tsx +302 -0
  152. package/studio/src/pages/QueryRunner.tsx +246 -0
  153. package/studio/src/pages/Table.tsx +94 -0
  154. package/studio/src/pages/Welcome.tsx +241 -0
  155. package/studio/src/routes.tsx +45 -0
  156. package/studio/src/store/archeTypeSettings.ts +30 -0
  157. package/studio/src/store/studio.ts +65 -0
  158. package/studio/src/utils/columnHelpers.tsx +114 -0
  159. package/studio/studio-instructions.md +81 -0
  160. package/studio/tailwind.config.js +77 -0
  161. package/studio/tsconfig.json +24 -0
  162. package/studio/utils.ts +54 -0
  163. package/studio/vite.config.js +19 -0
  164. package/swagger/generator.ts +1 -1
  165. package/tests/e2e/http.test.ts +126 -0
  166. package/tests/fixtures/archetypes/TestUserArchetype.ts +21 -0
  167. package/tests/fixtures/components/TestOrder.ts +23 -0
  168. package/tests/fixtures/components/TestProduct.ts +23 -0
  169. package/tests/fixtures/components/TestUser.ts +20 -0
  170. package/tests/fixtures/components/index.ts +6 -0
  171. package/tests/graphql/SchemaGeneration.test.ts +90 -0
  172. package/tests/graphql/builders/ResolverBuilder.test.ts +223 -0
  173. package/tests/graphql/builders/TypeDefBuilder.test.ts +153 -0
  174. package/tests/integration/archetype/ArcheType.persistence.test.ts +241 -0
  175. package/tests/integration/cache/CacheInvalidation.test.ts +259 -0
  176. package/tests/integration/entity/Entity.persistence.test.ts +333 -0
  177. package/tests/integration/query/Query.exec.test.ts +523 -0
  178. package/tests/pglite-setup.ts +61 -0
  179. package/tests/setup.ts +164 -0
  180. package/tests/stress/BenchmarkRunner.ts +203 -0
  181. package/tests/stress/DataSeeder.ts +190 -0
  182. package/tests/stress/StressTestReporter.ts +229 -0
  183. package/tests/stress/cursor-perf-test.ts +171 -0
  184. package/tests/stress/fixtures/StressTestComponents.ts +58 -0
  185. package/tests/stress/index.ts +7 -0
  186. package/tests/stress/scenarios/query-benchmarks.test.ts +285 -0
  187. package/tests/unit/BatchLoader.test.ts +82 -0
  188. package/tests/unit/archetype/ArcheType.test.ts +107 -0
  189. package/tests/unit/cache/CacheManager.test.ts +347 -0
  190. package/tests/unit/cache/MemoryCache.test.ts +260 -0
  191. package/tests/unit/cache/RedisCache.test.ts +411 -0
  192. package/tests/unit/entity/Entity.components.test.ts +244 -0
  193. package/tests/unit/entity/Entity.test.ts +345 -0
  194. package/tests/unit/gql/depthLimit.test.ts +203 -0
  195. package/tests/unit/gql/operationMiddleware.test.ts +293 -0
  196. package/tests/unit/health/Health.test.ts +129 -0
  197. package/tests/unit/middleware/AccessLog.test.ts +37 -0
  198. package/tests/unit/middleware/Middleware.test.ts +98 -0
  199. package/tests/unit/middleware/RequestId.test.ts +54 -0
  200. package/tests/unit/middleware/SecurityHeaders.test.ts +66 -0
  201. package/tests/unit/query/FilterBuilder.test.ts +111 -0
  202. package/tests/unit/query/Query.test.ts +308 -0
  203. package/tests/unit/scheduler/DistributedLock.test.ts +274 -0
  204. package/tests/unit/schema/schema-integration.test.ts +426 -0
  205. package/tests/unit/schema/schema.test.ts +580 -0
  206. package/tests/unit/storage/S3StorageProvider.test.ts +571 -0
  207. package/tests/unit/upload/RestUpload.test.ts +267 -0
  208. package/tests/unit/validateEnv.test.ts +82 -0
  209. package/tests/utils/entity-tracker.ts +57 -0
  210. package/tests/utils/index.ts +13 -0
  211. package/tests/utils/test-context.ts +149 -0
  212. package/tsconfig.json +5 -1
  213. package/types/archetype.types.ts +6 -0
  214. package/types/hooks.types.ts +1 -1
  215. package/types/query.types.ts +110 -0
  216. package/types/scheduler.types.ts +68 -7
  217. package/types/upload.types.ts +1 -0
  218. package/{core → upload}/FileValidator.ts +10 -1
  219. package/upload/RestUpload.ts +130 -0
  220. package/{core/components → upload}/UploadComponent.ts +11 -11
  221. package/{core → upload}/UploadManager.ts +3 -3
  222. package/upload/index.ts +23 -7
  223. package/utils/UploadHelper.ts +27 -6
  224. package/utils/cronParser.ts +16 -6
  225. package/.github/workflows/deploy-docs.yml +0 -57
  226. package/core/Components.ts +0 -202
  227. package/core/EntityCache.ts +0 -15
  228. package/core/Query.ts +0 -880
  229. package/docs/README.md +0 -149
  230. package/docs/_coverpage.md +0 -36
  231. package/docs/_sidebar.md +0 -23
  232. package/docs/api/core.md +0 -568
  233. package/docs/api/hooks.md +0 -554
  234. package/docs/api/index.md +0 -222
  235. package/docs/api/query.md +0 -678
  236. package/docs/api/service.md +0 -744
  237. package/docs/core-concepts/archetypes.md +0 -512
  238. package/docs/core-concepts/components.md +0 -498
  239. package/docs/core-concepts/entity.md +0 -314
  240. package/docs/core-concepts/hooks.md +0 -683
  241. package/docs/core-concepts/query.md +0 -588
  242. package/docs/core-concepts/services.md +0 -647
  243. package/docs/examples/code-examples.md +0 -425
  244. package/docs/getting-started.md +0 -337
  245. package/docs/index.html +0 -97
  246. package/tests/bench/insert.bench.ts +0 -60
  247. package/tests/bench/relations.bench.ts +0 -270
  248. package/tests/bench/sorting.bench.ts +0 -416
  249. package/tests/component-hooks-simple.test.ts +0 -117
  250. package/tests/component-hooks.test.ts +0 -1461
  251. package/tests/component.test.ts +0 -339
  252. package/tests/errorHandling.test.ts +0 -155
  253. package/tests/hooks.test.ts +0 -667
  254. package/tests/query-sorting.test.ts +0 -101
  255. package/tests/query.test.ts +0 -81
  256. package/tests/relations.test.ts +0 -170
  257. package/tests/scheduler.test.ts +0 -724
package/query/Query.ts ADDED
@@ -0,0 +1,1022 @@
1
+ import {ComponentRegistry , type BaseComponent, type ComponentDataType } from "../core/components";
2
+ import { Entity } from "../core/Entity";
3
+ import { logger } from "../core/Logger";
4
+ import db from "../database";
5
+ import { timed } from "../core/Decorators";
6
+ import { inList } from "../database/sqlHelpers";
7
+ import { QueryContext, QueryDAG, SourceNode, ComponentInclusionNode } from "./index";
8
+ import { OrQuery } from "./OrQuery";
9
+ import { OrNode } from "./OrNode";
10
+ import { preparedStatementCache } from "../database/PreparedStatementCache";
11
+ import { getMetadataStorage } from "../core/metadata";
12
+ import { shouldUseDirectPartition } from "../core/Config";
13
+ import type { SQL } from "bun";
14
+ import type { ComponentConstructor, TypedEntity, ComponentRecord } from "../types/query.types";
15
+
16
+ export type FilterOperator = "=" | ">" | "<" | ">=" | "<=" | "!=" | "LIKE" | "ILIKE" | "IN" | "NOT IN" | string;
17
+
18
+ export const FilterOp = {
19
+ EQ: "=" as FilterOperator,
20
+ GT: ">" as FilterOperator,
21
+ LT: "<" as FilterOperator,
22
+ GTE: ">=" as FilterOperator,
23
+ LTE: "<=" as FilterOperator,
24
+ NEQ: "!=" as FilterOperator,
25
+ LIKE: "LIKE" as FilterOperator,
26
+ ILIKE: "ILIKE" as FilterOperator,
27
+ IN: "IN" as FilterOperator,
28
+ NOT_IN: "NOT IN" as FilterOperator
29
+ }
30
+
31
+ export interface QueryFilter {
32
+ field: string;
33
+ operator: FilterOperator;
34
+ value: any;
35
+ }
36
+
37
+ export interface QueryFilterOptions {
38
+ filters: QueryFilter[];
39
+ }
40
+
41
+ export type SortDirection = "ASC" | "DESC";
42
+
43
+ export interface SortOrder {
44
+ component: string;
45
+ property: string;
46
+ direction: SortDirection;
47
+ nullsFirst?: boolean;
48
+ }
49
+
50
+ export interface ComponentWithFilters {
51
+ component: new (...args: any[]) => BaseComponent;
52
+ filters?: QueryFilter[];
53
+ }
54
+
55
+ export interface QueryCacheOptions {
56
+ preparedStatement?: boolean;
57
+ component?: boolean;
58
+ }
59
+
60
+ /**
61
+ * New Query class that uses DAG internally for better modularity and extensibility.
62
+ *
63
+ * Generic type parameter `TComponents` tracks component types added via `.with()`,
64
+ * enabling type-safe access to component data after query execution.
65
+ *
66
+ * @example
67
+ * ```typescript
68
+ * const entities = await new Query()
69
+ * .with(Position)
70
+ * .with(Velocity)
71
+ * .exec();
72
+ * // entities is TypedEntity<[typeof Position, typeof Velocity]>[]
73
+ * ```
74
+ */
75
+ class Query<TComponents extends readonly ComponentConstructor[] = []> {
76
+ private context: QueryContext;
77
+ private debug: boolean = false;
78
+ private orQuery: OrQuery | null = null;
79
+ private shouldPopulate: boolean = false;
80
+ private trx: SQL | undefined;
81
+ private skipPreparedCache: boolean = false;
82
+ private skipComponentCache: boolean = false;
83
+
84
+ /** Component constructors added to this query for type-safe access */
85
+ private _componentCtors: ComponentConstructor[] = [];
86
+
87
+ constructor(trx?: SQL) {
88
+ this.trx = trx;
89
+ this.context = new QueryContext(trx);
90
+ }
91
+
92
+ /**
93
+ * Get the database connection to use (transaction or default db)
94
+ */
95
+ private getDb(): SQL {
96
+ return this.trx ?? db;
97
+ }
98
+
99
+ public findById(id: string) {
100
+ // Validate ID to prevent PostgreSQL UUID parsing errors
101
+ if (!id || typeof id !== 'string' || id.trim() === '') {
102
+ throw new Error(`Query.findById called with invalid id: "${id}"`);
103
+ }
104
+ this.context.withId = id;
105
+ return this;
106
+ }
107
+
108
+ public async findOneById(id: string): Promise<TypedEntity<TComponents> | null> {
109
+ // Validate ID to prevent PostgreSQL UUID parsing errors
110
+ if (!id || typeof id !== 'string' || id.trim() === '') {
111
+ return null;
112
+ }
113
+ const entities = await this.findById(id).exec();
114
+ return entities.length > 0 ? entities[0]! : null;
115
+ }
116
+
117
+ /**
118
+ * Add a component requirement to the query with type accumulation.
119
+ * The returned Query tracks all component types for type-safe access after exec().
120
+ */
121
+ public with<T extends BaseComponent>(
122
+ componentCtor: new (...args: any[]) => T,
123
+ options?: QueryFilterOptions
124
+ ): Query<readonly [...TComponents, new (...args: any[]) => T]>;
125
+ public with(components: ComponentWithFilters[]): this;
126
+ public with(orQuery: OrQuery): this;
127
+ public with<T extends BaseComponent>(
128
+ componentCtorOrComponentsOrOrQuery: (new (...args: any[]) => T) | ComponentWithFilters[] | OrQuery,
129
+ options?: QueryFilterOptions
130
+ ): Query<readonly [...TComponents, new (...args: any[]) => T]> | this {
131
+ if (componentCtorOrComponentsOrOrQuery instanceof OrQuery) {
132
+ // Handle OR query
133
+ this.orQuery = componentCtorOrComponentsOrOrQuery;
134
+ return this;
135
+ }
136
+
137
+ if (Array.isArray(componentCtorOrComponentsOrOrQuery)) {
138
+ // Handle array of components with filters
139
+ for (const item of componentCtorOrComponentsOrOrQuery) {
140
+ const typeId = this.context.getComponentId(item.component);
141
+ if (!typeId) {
142
+ throw new Error(`Component ${item.component.name} is not registered.`);
143
+ }
144
+ this.context.componentIds.add(typeId);
145
+ this._componentCtors.push(item.component);
146
+
147
+ if (item.filters && item.filters.length > 0) {
148
+ this.context.componentFilters.set(typeId, item.filters);
149
+ }
150
+ }
151
+ } else {
152
+ // Handle single component
153
+ const typeId = this.context.getComponentId(componentCtorOrComponentsOrOrQuery);
154
+ if (!typeId) {
155
+ throw new Error(`Component ${componentCtorOrComponentsOrOrQuery.name} is not registered.`);
156
+ }
157
+ this.context.componentIds.add(typeId);
158
+ this._componentCtors.push(componentCtorOrComponentsOrOrQuery);
159
+
160
+ if (options?.filters && options.filters.length > 0) {
161
+ this.context.componentFilters.set(typeId, options.filters);
162
+ }
163
+ }
164
+
165
+ return this as unknown as Query<readonly [...TComponents, new (...args: any[]) => T]>;
166
+ }
167
+
168
+ public without<T extends BaseComponent>(ctor: new (...args: any[]) => T) {
169
+ const type_id = this.context.getComponentId(ctor);
170
+ if (!type_id) {
171
+ throw new Error(`Component ${ctor.name} is not registered.`);
172
+ }
173
+ this.context.excludedComponentIds.add(type_id);
174
+ return this;
175
+ }
176
+
177
+ public excludeEntityId(entityId: string): this {
178
+ this.context.excludedEntityIds.add(entityId);
179
+ return this;
180
+ }
181
+
182
+ public populate(): this {
183
+ this.shouldPopulate = true;
184
+ return this;
185
+ }
186
+
187
+ /**
188
+ * Eagerly load specific components after query execution.
189
+ * This preloads components into entities to avoid N+1 queries when accessing them later.
190
+ * @param ctors Array of component constructors to eagerly load
191
+ */
192
+ public eagerLoadComponents(ctors: Array<new () => BaseComponent>): this {
193
+ for (const ctor of ctors) {
194
+ const type_id = this.context.getComponentId(ctor);
195
+ if (!type_id) {
196
+ throw new Error(`Component ${ctor.name} is not registered.`);
197
+ }
198
+ this.context.eagerComponents.add(type_id);
199
+ }
200
+ return this;
201
+ }
202
+
203
+ /**
204
+ * Alias for eagerLoadComponents for backward compatibility
205
+ */
206
+ public eagerLoad<T extends BaseComponent>(ctors: (new (...args: any[]) => T)[]): this {
207
+ return this.eagerLoadComponents(ctors);
208
+ }
209
+
210
+ public take(limit: number): this {
211
+ this.context.limit = limit;
212
+ return this;
213
+ }
214
+
215
+ public offset(offset: number): this {
216
+ this.context.offsetValue = offset;
217
+ return this;
218
+ }
219
+
220
+ /**
221
+ * Use cursor-based pagination instead of OFFSET.
222
+ * Much more efficient for large datasets - O(1) instead of O(offset).
223
+ *
224
+ * @param cursorId - The entity ID to paginate from (exclusive)
225
+ * @param direction - 'after' for next page (default), 'before' for previous page
226
+ * @returns this for chaining
227
+ *
228
+ * @example
229
+ * // Get first page
230
+ * const page1 = await new Query().with(User).take(100).exec();
231
+ *
232
+ * // Get next page using cursor
233
+ * const lastId = page1[page1.length - 1].id;
234
+ * const page2 = await new Query().with(User).take(100).cursor(lastId).exec();
235
+ */
236
+ public cursor(cursorId: string, direction: 'after' | 'before' = 'after'): this {
237
+ this.context.cursorId = cursorId;
238
+ this.context.cursorDirection = direction;
239
+ // Clear offset when using cursor-based pagination
240
+ this.context.offsetValue = 0;
241
+ return this;
242
+ }
243
+
244
+ public sortBy<T extends BaseComponent>(
245
+ componentCtor: new (...args: any[]) => T,
246
+ property: keyof ComponentDataType<T>,
247
+ direction: SortDirection = "ASC",
248
+ nullsFirst: boolean = false
249
+ ): this {
250
+ const componentName = componentCtor.name;
251
+ const typeId = this.context.getComponentId(componentCtor);
252
+
253
+ if (!typeId) {
254
+ throw new Error(`Component ${componentName} is not registered.`);
255
+ }
256
+
257
+ // Validate that the component is required in this query
258
+ if (!this.context.componentIds.has(typeId)) {
259
+ throw new Error(`Cannot sort by component ${componentName} that is not included in the query. Use .with(${componentName}) first.`);
260
+ }
261
+
262
+ this.context.sortOrders.push({
263
+ component: componentName,
264
+ property: property as string,
265
+ direction,
266
+ nullsFirst
267
+ });
268
+
269
+ return this;
270
+ }
271
+
272
+ public debugMode(enabled: boolean = true): this {
273
+ this.debug = enabled;
274
+ return this;
275
+ }
276
+
277
+ /**
278
+ * Bypass cache for this query.
279
+ * @param options Cache options to bypass. If not provided, bypasses prepared statement cache.
280
+ */
281
+ public noCache(): this;
282
+ public noCache(options: QueryCacheOptions): this;
283
+ public noCache(options?: QueryCacheOptions): this {
284
+ if (!options) {
285
+ // Default behavior: bypass prepared statement cache
286
+ this.skipPreparedCache = true;
287
+ } else {
288
+ if (options.preparedStatement === true) {
289
+ this.skipPreparedCache = true;
290
+ }
291
+ if (options.component === true) {
292
+ this.skipComponentCache = true;
293
+ }
294
+ }
295
+ return this;
296
+ }
297
+
298
+ public count(): Promise<number> {
299
+ return new Promise<number>((resolve, reject) => {
300
+ const timeout = setTimeout(() => {
301
+ logger.error(`Query count execution timeout`);
302
+ reject(new Error(`Query count execution timeout after 30 seconds`));
303
+ }, 30000);
304
+ this.doCount()
305
+ .then(result => {
306
+ clearTimeout(timeout);
307
+ resolve(result);
308
+ })
309
+ .catch(error => {
310
+ clearTimeout(timeout);
311
+ reject(error);
312
+ });
313
+ });
314
+ }
315
+
316
+ /**
317
+ * Get an estimated count using PostgreSQL statistics.
318
+ * Much faster than exact count() for large tables - O(1) instead of O(n).
319
+ *
320
+ * Note: Returns approximate count based on PostgreSQL's statistics.
321
+ * Run ANALYZE on the table for more accurate estimates.
322
+ *
323
+ * @param component - The component class to count (uses its partition table)
324
+ * @returns Estimated count (may be up to 10% off for recently modified tables)
325
+ *
326
+ * @example
327
+ * // Fast approximate count
328
+ * const approxCount = await new Query().with(User).estimatedCount(User);
329
+ * console.log(`Approximately ${approxCount} users`);
330
+ */
331
+ public async estimatedCount(component: new (...args: any[]) => BaseComponent): Promise<number> {
332
+ const typeId = ComponentRegistry.getComponentId(component.name);
333
+ if (!typeId) {
334
+ throw new Error(`Component ${component.name} not registered`);
335
+ }
336
+
337
+ const tableName = ComponentRegistry.getPartitionTableName(typeId);
338
+ const dbConn = this.getDb();
339
+
340
+ // Use PostgreSQL's statistics for fast count estimate
341
+ // This queries pg_class which is O(1) instead of scanning the table
342
+ const sql = tableName && tableName !== 'components'
343
+ ? `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = $1`
344
+ : `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = 'entity_components'`;
345
+
346
+ const result = await dbConn.unsafe(sql, [tableName || 'entity_components']);
347
+
348
+ if (!result || result.length === 0 || result[0].estimate === null) {
349
+ // Fallback to exact count if statistics not available
350
+ return this.count();
351
+ }
352
+
353
+ return Number(result[0].estimate);
354
+ }
355
+
356
+ private async doCount(): Promise<number> {
357
+ // Build the DAG
358
+ const dag = new QueryDAG();
359
+
360
+ // Check if we have an OR query
361
+ if (this.orQuery) {
362
+ // For OR queries, we need to ensure entities have all required components first
363
+ if (this.context.componentIds.size > 0) {
364
+ // ComponentInclusionNode is the root, OrNode is the leaf
365
+ const componentNode = new ComponentInclusionNode();
366
+ dag.setRootNode(componentNode);
367
+
368
+ // OrNode filters on top of the base requirements
369
+ const orNode = new OrNode(this.orQuery);
370
+ orNode.addDependency(componentNode);
371
+ dag.addNode(orNode);
372
+ } else {
373
+ // No base requirements, OrNode is both root and leaf
374
+ const orNode = new OrNode(this.orQuery);
375
+ dag.setRootNode(orNode);
376
+ }
377
+ } else {
378
+ // Use buildBasicQuery for regular AND logic (includes CTE optimization)
379
+ const optimizedDag = QueryDAG.buildBasicQuery(this.context);
380
+ // Copy nodes from optimized DAG to our DAG
381
+ for (const node of optimizedDag.getNodes()) {
382
+ dag.addNode(node);
383
+ }
384
+ if (optimizedDag.getRootNode()) {
385
+ dag.setRootNode(optimizedDag.getRootNode()!);
386
+ }
387
+ }
388
+
389
+ // Execute the DAG
390
+ const result = dag.execute(this.context);
391
+
392
+ // Modify SQL for count
393
+ const countSql = `SELECT COUNT(*) as count FROM (${result.sql}) AS subquery`;
394
+
395
+ // Get the database connection (transaction or default)
396
+ const dbConn = this.getDb();
397
+
398
+ let countResult: any[];
399
+
400
+ if (this.skipPreparedCache) {
401
+ // Bypass cache - execute directly
402
+ countResult = await dbConn.unsafe(countSql, result.params);
403
+ } else {
404
+ // Check prepared statement cache
405
+ // Add 'count:' prefix to differentiate count queries from exec queries
406
+ const cacheKey = 'count:' + this.context.generateCacheKey();
407
+ const { statement, isHit } = await preparedStatementCache.getOrCreate(countSql, cacheKey, dbConn);
408
+ countResult = await preparedStatementCache.execute(statement, result.params, dbConn);
409
+ }
410
+
411
+ // Debug logging
412
+ if (this.debug) {
413
+ console.log('🔍 Query Count Debug:');
414
+ console.log('SQL:', countSql);
415
+ console.log('Params:', result.params);
416
+ console.log('Prepared Cache Bypass:', this.skipPreparedCache);
417
+ console.log('Component Cache Bypass:', this.skipComponentCache);
418
+ console.log('Using Transaction:', !!this.trx);
419
+ console.log('---');
420
+ }
421
+
422
+ // Validate params before execution to catch UUID errors early
423
+ for (let i = 0; i < result.params.length; i++) {
424
+ const param = result.params[i];
425
+ if (param === '' || (typeof param === 'string' && param.trim() === '')) {
426
+ logger.error(`Empty string parameter detected at position ${i + 1} in count query`);
427
+ throw new Error(`Query count parameter $${i + 1} is an empty string. This will cause PostgreSQL UUID parsing errors.`);
428
+ }
429
+ }
430
+
431
+ // Safely extract count from result - handle undefined/null cases
432
+ if (!countResult || countResult.length === 0 || countResult[0] === undefined) {
433
+ return 0;
434
+ }
435
+
436
+ // PostgreSQL COUNT(*) returns a value, handle both string and number
437
+ const count = countResult[0].count;
438
+ if (count === undefined || count === null) {
439
+ return 0;
440
+ }
441
+ return typeof count === 'string' ? parseInt(count, 10) : Number(count);
442
+ }
443
+
444
+ /**
445
+ * Calculate the sum of a numeric field across all matching entities.
446
+ * The component must be included in the query via .with().
447
+ * @param componentCtor The component class containing the field
448
+ * @param field The field name to sum (must be numeric)
449
+ * @returns Promise resolving to the sum, or 0 if no matches
450
+ */
451
+ public sum<T extends BaseComponent>(
452
+ componentCtor: new (...args: any[]) => T,
453
+ field: keyof ComponentDataType<T>
454
+ ): Promise<number> {
455
+ return new Promise<number>((resolve, reject) => {
456
+ const timeout = setTimeout(() => {
457
+ logger.error(`Query sum execution timeout`);
458
+ reject(new Error(`Query sum execution timeout after 30 seconds`));
459
+ }, 30000);
460
+ this.doAggregate('SUM', componentCtor, field as string)
461
+ .then(result => {
462
+ clearTimeout(timeout);
463
+ resolve(result);
464
+ })
465
+ .catch(error => {
466
+ clearTimeout(timeout);
467
+ reject(error);
468
+ });
469
+ });
470
+ }
471
+
472
+ /**
473
+ * Calculate the average of a numeric field across all matching entities.
474
+ * The component must be included in the query via .with().
475
+ * @param componentCtor The component class containing the field
476
+ * @param field The field name to average (must be numeric)
477
+ * @returns Promise resolving to the average, or 0 if no matches
478
+ */
479
+ public average<T extends BaseComponent>(
480
+ componentCtor: new (...args: any[]) => T,
481
+ field: keyof ComponentDataType<T>
482
+ ): Promise<number> {
483
+ return new Promise<number>((resolve, reject) => {
484
+ const timeout = setTimeout(() => {
485
+ logger.error(`Query average execution timeout`);
486
+ reject(new Error(`Query average execution timeout after 30 seconds`));
487
+ }, 30000);
488
+ this.doAggregate('AVG', componentCtor, field as string)
489
+ .then(result => {
490
+ clearTimeout(timeout);
491
+ resolve(result);
492
+ })
493
+ .catch(error => {
494
+ clearTimeout(timeout);
495
+ reject(error);
496
+ });
497
+ });
498
+ }
499
+
500
+ /**
501
+ * Internal method to perform aggregate operations (SUM, AVG) on component fields.
502
+ * Uses an optimized single-pass approach by joining to the component table
503
+ * directly within the CTE-based query.
504
+ */
505
+ private async doAggregate(
506
+ aggregateType: 'SUM' | 'AVG',
507
+ componentCtor: new (...args: any[]) => BaseComponent,
508
+ field: string
509
+ ): Promise<number> {
510
+ // Get the component type ID
511
+ const typeId = this.context.getComponentId(componentCtor);
512
+ if (!typeId) {
513
+ throw new Error(`Component ${componentCtor.name} is not registered.`);
514
+ }
515
+
516
+ // Validate that the component is in the query
517
+ if (!this.context.componentIds.has(typeId)) {
518
+ throw new Error(
519
+ `Cannot aggregate on component ${componentCtor.name} that is not included in the query. ` +
520
+ `Use .with(${componentCtor.name}) first.`
521
+ );
522
+ }
523
+
524
+ // Reset context for fresh execution
525
+ this.context.reset();
526
+
527
+ // Build the DAG
528
+ const dag = new QueryDAG();
529
+
530
+ // Check if we have an OR query
531
+ if (this.orQuery) {
532
+ if (this.context.componentIds.size > 0) {
533
+ const componentNode = new ComponentInclusionNode();
534
+ dag.setRootNode(componentNode);
535
+
536
+ const orNode = new OrNode(this.orQuery);
537
+ orNode.addDependency(componentNode);
538
+ dag.addNode(orNode);
539
+ } else {
540
+ const orNode = new OrNode(this.orQuery);
541
+ dag.setRootNode(orNode);
542
+ }
543
+ } else {
544
+ const optimizedDag = QueryDAG.buildBasicQuery(this.context);
545
+ for (const node of optimizedDag.getNodes()) {
546
+ dag.addNode(node);
547
+ }
548
+ if (optimizedDag.getRootNode()) {
549
+ dag.setRootNode(optimizedDag.getRootNode()!);
550
+ }
551
+ }
552
+
553
+ // Execute the DAG to get the base query
554
+ const result = dag.execute(this.context);
555
+
556
+ // Determine the component table name
557
+ const componentTableName = shouldUseDirectPartition()
558
+ ? (ComponentRegistry.getPartitionTableName(typeId) || 'components')
559
+ : 'components';
560
+
561
+ // Build the JSON path for the field
562
+ let jsonPath: string;
563
+ if (field.includes('.')) {
564
+ const parts = field.split('.');
565
+ const lastPart = parts.pop()!;
566
+ const nestedPath = parts.map(p => `'${p}'`).join('->');
567
+ jsonPath = `c.data->${nestedPath}->>'${lastPart}'`;
568
+ } else {
569
+ jsonPath = `c.data->>'${field}'`;
570
+ }
571
+
572
+ // Add the type_id parameter for the JOIN condition
573
+ const typeIdParamIndex = this.context.addParam(typeId);
574
+
575
+ // Build aggregate SQL by wrapping the entity query as a subquery
576
+ // This approach works consistently regardless of CTE usage
577
+ // The base query returns entity_id (aliased as 'id'), which we join to components
578
+ const aggregateSql = `
579
+ SELECT ${aggregateType}((${jsonPath})::numeric) as result
580
+ FROM (${result.sql}) AS entity_subq
581
+ JOIN ${componentTableName} c ON c.entity_id = entity_subq.id
582
+ WHERE c.type_id = $${typeIdParamIndex}
583
+ AND c.deleted_at IS NULL`;
584
+
585
+ // Get the database connection
586
+ const dbConn = this.getDb();
587
+
588
+ let aggregateResult: any[];
589
+
590
+ if (this.skipPreparedCache) {
591
+ aggregateResult = await dbConn.unsafe(aggregateSql, result.params);
592
+ } else {
593
+ const cacheKey = `${aggregateType.toLowerCase()}:${typeId}:${field}:` + this.context.generateCacheKey();
594
+ const { statement } = await preparedStatementCache.getOrCreate(aggregateSql, cacheKey, dbConn);
595
+ aggregateResult = await preparedStatementCache.execute(statement, result.params, dbConn);
596
+ }
597
+
598
+ // Debug logging
599
+ if (this.debug) {
600
+ console.log(`🔍 Query ${aggregateType} Debug:`);
601
+ console.log('SQL:', aggregateSql);
602
+ console.log('Params:', result.params);
603
+ console.log('Component:', componentCtor.name);
604
+ console.log('Field:', field);
605
+ console.log('---');
606
+ }
607
+
608
+ // Validate params
609
+ for (let i = 0; i < result.params.length; i++) {
610
+ const param = result.params[i];
611
+ if (param === '' || (typeof param === 'string' && param.trim() === '')) {
612
+ logger.error(`Empty string parameter detected at position ${i + 1} in ${aggregateType} query`);
613
+ throw new Error(`Query ${aggregateType} parameter $${i + 1} is an empty string.`);
614
+ }
615
+ }
616
+
617
+ // Extract result
618
+ if (!aggregateResult || aggregateResult.length === 0 || aggregateResult[0] === undefined) {
619
+ return 0;
620
+ }
621
+
622
+ const value = aggregateResult[0].result;
623
+ if (value === undefined || value === null) {
624
+ return 0;
625
+ }
626
+ return typeof value === 'string' ? parseFloat(value) : Number(value);
627
+ }
628
+
629
+ /**
630
+ * Execute the query and return typed entities.
631
+ *
632
+ * When components are added via `.with()`, the returned entities have:
633
+ * - `getTyped(Ctor)`: Type-safe async getter (returns non-null since query guarantees existence)
634
+ * - `componentData`: Synchronous access to already-loaded component data
635
+ *
636
+ * @returns Promise resolving to array of TypedEntity with accumulated component types
637
+ */
638
+ @timed("Query.exec")
639
+ public async exec(): Promise<TypedEntity<TComponents>[]> {
640
+ return new Promise<TypedEntity<TComponents>[]>((resolve, reject) => {
641
+ // Add timeout to prevent hanging queries
642
+ const timeout = setTimeout(() => {
643
+ logger.error(`Query execution timeout`);
644
+ reject(new Error(`Query execution timeout after 30 seconds`));
645
+ }, 30000); // 30 second timeout
646
+
647
+ this.doExec()
648
+ .then(result => {
649
+ clearTimeout(timeout);
650
+ // Wrap entities with typed accessors
651
+ const typedEntities = result.map(e => this.wrapTypedEntity(e));
652
+ resolve(typedEntities);
653
+ })
654
+ .catch(error => {
655
+ clearTimeout(timeout);
656
+ reject(error);
657
+ });
658
+ });
659
+ }
660
+
661
+ /**
662
+ * Wrap an entity with typed accessors for components in this query.
663
+ * Provides both async getTyped() and synchronous componentData access.
664
+ */
665
+ private wrapTypedEntity(entity: Entity): TypedEntity<TComponents> {
666
+ const componentCtors = this._componentCtors;
667
+
668
+ // Build synchronous component data record from already-loaded components
669
+ const componentData: Record<string, any> = {};
670
+ for (const ctor of componentCtors) {
671
+ const comp = entity.getInMemory(ctor);
672
+ if (comp) {
673
+ componentData[ctor.name] = (comp as any).data();
674
+ }
675
+ }
676
+
677
+ // Create typed entity wrapper
678
+ const typedEntity = entity as TypedEntity<TComponents>;
679
+
680
+ // Define componentData property
681
+ Object.defineProperty(typedEntity, 'componentData', {
682
+ value: componentData as ComponentRecord<TComponents>,
683
+ writable: false,
684
+ enumerable: true
685
+ });
686
+
687
+ // Define _queriedComponents property for runtime reflection
688
+ Object.defineProperty(typedEntity, '_queriedComponents', {
689
+ value: componentCtors as unknown as TComponents,
690
+ writable: false,
691
+ enumerable: false
692
+ });
693
+
694
+ // Define getTyped method
695
+ Object.defineProperty(typedEntity, 'getTyped', {
696
+ value: async function<T extends TComponents[number]>(
697
+ ctor: T
698
+ ): Promise<T extends ComponentConstructor<infer C> ? C extends BaseComponent ? ComponentDataType<C> : never : never> {
699
+ const data = await entity.get(ctor as any);
700
+ if (!data) {
701
+ throw new Error(`Component ${(ctor as any).name} not found on entity ${entity.id}, but it was expected from query`);
702
+ }
703
+ return data as any;
704
+ },
705
+ writable: false,
706
+ enumerable: false
707
+ });
708
+
709
+ return typedEntity;
710
+ }
711
+
712
+ private async doExec(): Promise<Entity[]> {
713
+ // Reset context for fresh execution
714
+ this.context.reset();
715
+
716
+ // Build the DAG
717
+ const dag = new QueryDAG();
718
+
719
+ // Check if we have an OR query
720
+ if (this.orQuery) {
721
+ // For OR queries, we need to ensure entities have all required components first
722
+ if (this.context.componentIds.size > 0) {
723
+ // ComponentInclusionNode is the root, OrNode is the leaf
724
+ const componentNode = new ComponentInclusionNode();
725
+ dag.setRootNode(componentNode);
726
+
727
+ // OrNode filters on top of the base requirements
728
+ const orNode = new OrNode(this.orQuery);
729
+ orNode.addDependency(componentNode);
730
+ dag.addNode(orNode);
731
+ } else {
732
+ // No base requirements, OrNode is both root and leaf
733
+ const orNode = new OrNode(this.orQuery);
734
+ dag.setRootNode(orNode);
735
+ }
736
+ } else {
737
+ // Use buildBasicQuery for regular AND logic (includes CTE optimization)
738
+ const optimizedDag = QueryDAG.buildBasicQuery(this.context);
739
+ // Copy nodes from optimized DAG to our DAG
740
+ for (const node of optimizedDag.getNodes()) {
741
+ dag.addNode(node);
742
+ }
743
+ if (optimizedDag.getRootNode()) {
744
+ dag.setRootNode(optimizedDag.getRootNode()!);
745
+ }
746
+ }
747
+
748
+ // Execute the DAG
749
+ const result = dag.execute(this.context);
750
+
751
+ // Get the database connection (transaction or default)
752
+ const dbConn = this.getDb();
753
+
754
+ // Debug logging
755
+ if (this.debug) {
756
+ console.log('🔍 Query Debug:');
757
+ console.log('SQL:', result.sql);
758
+ console.log('Params:', result.params);
759
+ console.log('OR Query:', !!this.orQuery);
760
+ console.log('Prepared Cache Bypass:', this.skipPreparedCache);
761
+ console.log('Component Cache Bypass:', this.skipComponentCache);
762
+ console.log('Using Transaction:', !!this.trx);
763
+ console.log('---');
764
+ }
765
+
766
+ // Validate params before execution to catch UUID errors early
767
+ for (let i = 0; i < result.params.length; i++) {
768
+ const param = result.params[i];
769
+ if (param === '' || (typeof param === 'string' && param.trim() === '')) {
770
+ logger.error(`Empty string parameter detected at position ${i + 1}: SQL=${result.sql.substring(0, 200)}`);
771
+ throw new Error(`Query parameter $${i + 1} is an empty string. This will cause PostgreSQL UUID parsing errors. SQL: ${result.sql.substring(0, 100)}...`);
772
+ }
773
+ }
774
+
775
+ // Validate parameters before execution
776
+ for (let i = 0; i < result.params.length; i++) {
777
+ if (result.params[i] === undefined || result.params[i] === null) {
778
+ console.error(`❌ Query parameter $${i + 1} is undefined/null`);
779
+ console.error(`SQL: ${result.sql}`);
780
+ console.error(`All params: ${JSON.stringify(result.params)}`);
781
+ throw new Error(`Query parameter $${i + 1} is undefined/null. SQL: ${result.sql.substring(0, 100)}...`);
782
+ }
783
+ }
784
+
785
+ let entities: any[];
786
+
787
+ if (this.orQuery || this.skipPreparedCache) {
788
+ // For OR queries or explicit cache bypass, execute directly
789
+ // This avoids potential parameter type inference issues with Bun's SQL
790
+ entities = await dbConn.unsafe(result.sql, result.params);
791
+ } else {
792
+ // Check prepared statement cache for regular queries
793
+ const cacheKey = this.context.generateCacheKey();
794
+ const { statement, isHit } = await preparedStatementCache.getOrCreate(result.sql, cacheKey, dbConn);
795
+ entities = await preparedStatementCache.execute(statement, result.params, dbConn);
796
+ }
797
+
798
+ // Convert to Entity objects
799
+ const entityIds: string[] = entities.map((row: any) => row.id);
800
+
801
+ if (entityIds.length === 0) {
802
+ return [];
803
+ }
804
+
805
+ // Create Entity objects
806
+ const entityMap = new Map<string, Entity>();
807
+ for (const id of entityIds) {
808
+ const entity = new Entity(id);
809
+ entity.setPersisted(true);
810
+ entity.setDirty(false);
811
+ entityMap.set(id, entity);
812
+ }
813
+
814
+ // Populate entities with components if requested
815
+ if (this.shouldPopulate && this.context.componentIds.size > 0) {
816
+ await this.populateComponents(entityMap);
817
+ }
818
+
819
+ // Eagerly load specific components if requested
820
+ if (this.context.eagerComponents.size > 0) {
821
+ const entitiesArray = Array.from(entityMap.values());
822
+ await Entity.LoadComponents(entitiesArray, Array.from(this.context.eagerComponents), this.skipComponentCache);
823
+ }
824
+
825
+ // Return entities in the same order as the query results
826
+ const finalEntities = entityIds.map(id => entityMap.get(id)!);
827
+
828
+ return finalEntities;
829
+ }
830
+
831
+ /**
832
+ * Bulk fetch and attach components to entities
833
+ * @private
834
+ */
835
+ private async populateComponents(entityMap: Map<string, Entity>): Promise<void> {
836
+ const entityIds = Array.from(entityMap.keys());
837
+ const componentTypeIds = Array.from(this.context.componentIds);
838
+
839
+ if (entityIds.length === 0 || componentTypeIds.length === 0) {
840
+ return;
841
+ }
842
+
843
+ // Bulk fetch all components for all entities and all requested component types
844
+ const entityIdList = inList(entityIds, 1);
845
+ const typeIdList = inList(componentTypeIds, entityIdList.newParamIndex);
846
+
847
+ // Get the database connection (transaction or default)
848
+ const dbConn = this.getDb();
849
+
850
+ let components: any[];
851
+ if (shouldUseDirectPartition() && componentTypeIds.length === 1) {
852
+ // Single component type - use direct partition if available
853
+ const partitionTableName = ComponentRegistry.getPartitionTableName(componentTypeIds[0]!);
854
+ if (partitionTableName) {
855
+ components = await dbConn.unsafe(`
856
+ SELECT id, entity_id, type_id, data
857
+ FROM ${partitionTableName}
858
+ WHERE entity_id IN ${entityIdList.sql}
859
+ AND type_id IN ${typeIdList.sql}
860
+ AND deleted_at IS NULL
861
+ `, [...entityIdList.params, ...typeIdList.params]);
862
+ } else {
863
+ // Fallback to parent table
864
+ components = await dbConn.unsafe(`
865
+ SELECT id, entity_id, type_id, data
866
+ FROM components
867
+ WHERE entity_id IN ${entityIdList.sql}
868
+ AND type_id IN ${typeIdList.sql}
869
+ AND deleted_at IS NULL
870
+ `, [...entityIdList.params, ...typeIdList.params]);
871
+ }
872
+ } else {
873
+ // Multiple types or direct partition disabled - use parent table
874
+ components = await dbConn.unsafe(`
875
+ SELECT id, entity_id, type_id, data
876
+ FROM components
877
+ WHERE entity_id IN ${entityIdList.sql}
878
+ AND type_id IN ${typeIdList.sql}
879
+ AND deleted_at IS NULL
880
+ `, [...entityIdList.params, ...typeIdList.params]);
881
+ }
882
+
883
+ // Get metadata storage for Date deserialization
884
+ const storage = getMetadataStorage();
885
+
886
+ // Group components by entity_id and attach them to entities
887
+ for (const row of components) {
888
+ const entity = entityMap.get(row.entity_id);
889
+ if (!entity) continue;
890
+
891
+ // Get the component constructor from registry
892
+ const ComponentCtor = ComponentRegistry.getConstructor(row.type_id);
893
+ if (!ComponentCtor) {
894
+ logger.warn(`Component constructor not found for type_id: ${row.type_id}`);
895
+ continue;
896
+ }
897
+
898
+ // Create component instance
899
+ const component = new ComponentCtor();
900
+
901
+ // Parse and assign component data
902
+ const componentData = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
903
+ Object.assign(component, componentData);
904
+
905
+ // Deserialize Date properties
906
+ const props = storage.componentProperties.get(row.type_id);
907
+ if (props) {
908
+ for (const prop of props) {
909
+ if (prop.propertyType === Date && typeof (component as any)[prop.propertyKey] === 'string') {
910
+ (component as any)[prop.propertyKey] = new Date((component as any)[prop.propertyKey]);
911
+ }
912
+ }
913
+ }
914
+
915
+ // Set component metadata
916
+ component.id = row.id;
917
+ component.setPersisted(true);
918
+ component.setDirty(false);
919
+
920
+ // Add component to entity (using protected method)
921
+ (entity as any).addComponent(component);
922
+ }
923
+ }
924
+
925
+ /**
926
+ * Execute query with EXPLAIN ANALYZE for performance debugging
927
+ * Returns the query plan and execution statistics
928
+ */
929
+ public async explainAnalyze(buffers: boolean = true): Promise<string> {
930
+ // Reset context for fresh execution
931
+ this.context.reset();
932
+
933
+ // Build the DAG (same as exec)
934
+ const dag = new QueryDAG();
935
+
936
+ if (this.orQuery) {
937
+ if (this.context.componentIds.size > 0) {
938
+ const componentNode = new ComponentInclusionNode();
939
+ dag.setRootNode(componentNode);
940
+
941
+ const orNode = new OrNode(this.orQuery);
942
+ orNode.addDependency(componentNode);
943
+ dag.addNode(orNode);
944
+ } else {
945
+ const orNode = new OrNode(this.orQuery);
946
+ dag.setRootNode(orNode);
947
+ }
948
+ } else {
949
+ const optimizedDag = QueryDAG.buildBasicQuery(this.context);
950
+ for (const node of optimizedDag.getNodes()) {
951
+ dag.addNode(node);
952
+ }
953
+ if (optimizedDag.getRootNode()) {
954
+ dag.setRootNode(optimizedDag.getRootNode()!);
955
+ }
956
+ }
957
+
958
+ // Execute the DAG
959
+ const result = dag.execute(this.context);
960
+
961
+ // Create EXPLAIN ANALYZE query
962
+ const explainSql = `EXPLAIN (ANALYZE${buffers ? ', BUFFERS' : ''}) ${result.sql}`;
963
+
964
+ // Get the database connection (transaction or default)
965
+ const dbConn = this.getDb();
966
+
967
+ // Debug logging
968
+ if (this.debug) {
969
+ console.log('🔍 Query EXPLAIN ANALYZE Debug:');
970
+ console.log('SQL:', explainSql);
971
+ console.log('Params:', result.params);
972
+ console.log('Using Transaction:', !!this.trx);
973
+ console.log('---');
974
+ }
975
+
976
+ // Execute the EXPLAIN ANALYZE query
977
+ const explainResult = await dbConn.unsafe(explainSql, result.params);
978
+
979
+ // Format the result
980
+ return explainResult.map((row: any) => row['QUERY PLAN']).join('\n');
981
+ }
982
+
983
+ /**
984
+ * Get prepared statement cache statistics
985
+ */
986
+ public static getCacheStats() {
987
+ return preparedStatementCache.getStats();
988
+ }
989
+
990
+ static filterOp = FilterOp;
991
+
992
+ public static filter(field: string, operator: FilterOperator, value: any): QueryFilter {
993
+ // Validate value to catch empty strings early
994
+ if (value === '' || (typeof value === 'string' && value.trim() === '')) {
995
+ throw new Error(`Query.filter: Cannot create filter for field "${field}" with empty string value. This would cause PostgreSQL UUID parsing errors.`);
996
+ }
997
+ return { field, operator, value };
998
+ }
999
+
1000
+ public static typedFilter<T extends BaseComponent>(
1001
+ componentCtor: new (...args: any[]) => T,
1002
+ field: keyof ComponentDataType<T>,
1003
+ operator: FilterOperator,
1004
+ value: any
1005
+ ): QueryFilter {
1006
+ return { field: field as string, operator, value };
1007
+ }
1008
+
1009
+ public static filters(...filters: QueryFilter[]): QueryFilterOptions {
1010
+ return { filters };
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * OR function for combining component filters
1016
+ * Creates an OrQuery that matches entities satisfying ANY of the branches
1017
+ */
1018
+ export function or(branches: ComponentWithFilters[]): OrQuery {
1019
+ return new OrQuery(branches);
1020
+ }
1021
+
1022
+ export { Query };