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
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Integration tests for Query execution
3
+ * Tests query execution against the database
4
+ */
5
+ import { describe, test, expect, beforeAll, beforeEach, afterEach } from 'bun:test';
6
+ import { Entity } from '../../../core/Entity';
7
+ import { Query, FilterOp } from '../../../query/Query';
8
+ import { TestUser, TestProduct, TestOrder } from '../../fixtures/components';
9
+ import { createTestContext, ensureComponentsRegistered } from '../../utils';
10
+
11
+ describe('Query Execution', () => {
12
+ const ctx = createTestContext();
13
+
14
+ beforeAll(async () => {
15
+ await ensureComponentsRegistered(TestUser, TestProduct, TestOrder);
16
+ });
17
+
18
+ describe('basic query execution', () => {
19
+ test('exec() returns entities with specified component', async () => {
20
+ const entity = ctx.tracker.create();
21
+ entity.add(TestUser, { name: 'QueryTest', email: 'query@example.com', age: 30 });
22
+ await entity.save();
23
+
24
+ const results = await new Query()
25
+ .with(TestUser)
26
+ .exec();
27
+
28
+ expect(results.length).toBeGreaterThanOrEqual(1);
29
+ });
30
+
31
+ test('populate() loads all component data', async () => {
32
+ const entity = ctx.tracker.create();
33
+ entity.add(TestUser, { name: 'PopulateTest', email: 'populate@example.com', age: 25 });
34
+ await entity.save();
35
+
36
+ const results = await new Query()
37
+ .with(TestUser)
38
+ .populate()
39
+ .exec();
40
+
41
+ const found = results.find(e => e.id === entity.id);
42
+ expect(found).toBeDefined();
43
+ expect(found?.getInMemory(TestUser)).toBeDefined();
44
+ });
45
+
46
+ test('returns empty array when no matches', async () => {
47
+ // Query for entities with a unique component
48
+ const results = await new Query()
49
+ .with(TestUser, {
50
+ filters: [Query.filter('email', FilterOp.EQ, 'definitely-does-not-exist@nowhere.com')]
51
+ })
52
+ .exec();
53
+
54
+ expect(results.length).toBe(0);
55
+ });
56
+ });
57
+
58
+ describe('findById()', () => {
59
+ test('finds entity by ID', async () => {
60
+ const entity = ctx.tracker.create();
61
+ entity.add(TestUser, { name: 'FindById', email: 'findby@example.com', age: 35 });
62
+ await entity.save();
63
+
64
+ const results = await new Query()
65
+ .findById(entity.id)
66
+ .exec();
67
+
68
+ expect(results.length).toBe(1);
69
+ expect(results[0]!.id).toBe(entity.id);
70
+ });
71
+
72
+ test('findOneById returns single entity', async () => {
73
+ const entity = ctx.tracker.create();
74
+ entity.add(TestUser, { name: 'FindOne', email: 'findone@example.com', age: 40 });
75
+ await entity.save();
76
+
77
+ const result = await new Query().findOneById(entity.id);
78
+
79
+ expect(result).not.toBeNull();
80
+ expect(result?.id).toBe(entity.id);
81
+ });
82
+
83
+ test('findOneById returns null for non-existent ID', async () => {
84
+ const result = await new Query().findOneById('00000000-0000-0000-0000-000000000000');
85
+ expect(result).toBeNull();
86
+ });
87
+ });
88
+
89
+ describe('filtering', () => {
90
+ beforeEach(async () => {
91
+ // Create test data
92
+ const entity1 = ctx.tracker.create();
93
+ entity1.add(TestUser, { name: 'Alice', email: 'alice@example.com', age: 25 });
94
+ await entity1.save();
95
+
96
+ const entity2 = ctx.tracker.create();
97
+ entity2.add(TestUser, { name: 'Bob', email: 'bob@example.com', age: 35 });
98
+ await entity2.save();
99
+
100
+ const entity3 = ctx.tracker.create();
101
+ entity3.add(TestUser, { name: 'Charlie', email: 'charlie@example.com', age: 45 });
102
+ await entity3.save();
103
+ });
104
+
105
+ test('EQ filter finds exact match', async () => {
106
+ const results = await new Query()
107
+ .with(TestUser, {
108
+ filters: [Query.filter('name', FilterOp.EQ, 'Alice')]
109
+ })
110
+ .populate()
111
+ .exec();
112
+
113
+ const alice = results.find(e => e.getInMemory(TestUser)?.name === 'Alice');
114
+ expect(alice).toBeDefined();
115
+ });
116
+
117
+ test('GT filter finds greater values', async () => {
118
+ const results = await new Query()
119
+ .with(TestUser, {
120
+ filters: [Query.filter('age', FilterOp.GT, 30)]
121
+ })
122
+ .populate()
123
+ .exec();
124
+
125
+ for (const entity of results) {
126
+ const user = entity.getInMemory(TestUser);
127
+ if (user) {
128
+ expect(user.age).toBeGreaterThan(30);
129
+ }
130
+ }
131
+ });
132
+
133
+ test('LT filter finds lesser values', async () => {
134
+ const results = await new Query()
135
+ .with(TestUser, {
136
+ filters: [Query.filter('age', FilterOp.LT, 40)]
137
+ })
138
+ .populate()
139
+ .exec();
140
+
141
+ for (const entity of results) {
142
+ const user = entity.getInMemory(TestUser);
143
+ if (user) {
144
+ expect(user.age).toBeLessThan(40);
145
+ }
146
+ }
147
+ });
148
+
149
+ test('LIKE filter finds partial matches', async () => {
150
+ const results = await new Query()
151
+ .with(TestUser, {
152
+ filters: [Query.filter('email', FilterOp.LIKE, '%example.com')]
153
+ })
154
+ .populate()
155
+ .exec();
156
+
157
+ expect(results.length).toBeGreaterThanOrEqual(1);
158
+ });
159
+ });
160
+
161
+ describe('pagination', () => {
162
+ beforeEach(async () => {
163
+ // Create multiple entities for pagination
164
+ for (let i = 0; i < 5; i++) {
165
+ const entity = ctx.tracker.create();
166
+ entity.add(TestUser, {
167
+ name: `PaginationUser${i}`,
168
+ email: `page${i}@example.com`,
169
+ age: 20 + i
170
+ });
171
+ await entity.save();
172
+ }
173
+ });
174
+
175
+ test('take() limits results', async () => {
176
+ const results = await new Query()
177
+ .with(TestUser)
178
+ .take(2)
179
+ .exec();
180
+
181
+ expect(results.length).toBeLessThanOrEqual(2);
182
+ });
183
+
184
+ test('offset() skips results', async () => {
185
+ const allResults = await new Query()
186
+ .with(TestUser)
187
+ .exec();
188
+
189
+ const offsetResults = await new Query()
190
+ .with(TestUser)
191
+ .offset(2)
192
+ .take(100)
193
+ .exec();
194
+
195
+ expect(offsetResults.length).toBe(Math.max(0, allResults.length - 2));
196
+ });
197
+
198
+ test('take() and offset() work together', async () => {
199
+ const results = await new Query()
200
+ .with(TestUser)
201
+ .take(2)
202
+ .offset(1)
203
+ .exec();
204
+
205
+ expect(results.length).toBeLessThanOrEqual(2);
206
+ });
207
+ });
208
+
209
+ describe('sorting', () => {
210
+ beforeEach(async () => {
211
+ const ages = [30, 20, 40, 25, 35];
212
+ for (let i = 0; i < ages.length; i++) {
213
+ const entity = ctx.tracker.create();
214
+ entity.add(TestUser, {
215
+ name: `SortUser${i}`,
216
+ email: `sort${i}@example.com`,
217
+ age: ages[i]!
218
+ });
219
+ await entity.save();
220
+ }
221
+ });
222
+
223
+ test('sortBy ASC orders correctly', async () => {
224
+ const results = await new Query()
225
+ .with(TestUser, {
226
+ filters: [Query.filter('email', FilterOp.LIKE, 'sort%@example.com')]
227
+ })
228
+ .sortBy(TestUser, 'age', 'ASC')
229
+ .populate()
230
+ .exec();
231
+
232
+ for (let i = 1; i < results.length; i++) {
233
+ const prevAge = results[i - 1]!.getInMemory(TestUser)?.age ?? 0;
234
+ const currAge = results[i]!.getInMemory(TestUser)?.age ?? 0;
235
+ expect(currAge).toBeGreaterThanOrEqual(prevAge);
236
+ }
237
+ });
238
+
239
+ test('sortBy DESC orders correctly', async () => {
240
+ const results = await new Query()
241
+ .with(TestUser, {
242
+ filters: [Query.filter('email', FilterOp.LIKE, 'sort%@example.com')]
243
+ })
244
+ .sortBy(TestUser, 'age', 'DESC')
245
+ .populate()
246
+ .exec();
247
+
248
+ for (let i = 1; i < results.length; i++) {
249
+ const prevAge = results[i - 1]!.getInMemory(TestUser)?.age ?? 0;
250
+ const currAge = results[i]!.getInMemory(TestUser)?.age ?? 0;
251
+ expect(currAge).toBeLessThanOrEqual(prevAge);
252
+ }
253
+ });
254
+ });
255
+
256
+ describe('count()', () => {
257
+ test('returns count of matching entities', async () => {
258
+ const entity = ctx.tracker.create();
259
+ entity.add(TestUser, { name: 'CountTest', email: 'count@example.com', age: 30 });
260
+ await entity.save();
261
+
262
+ const count = await new Query()
263
+ .with(TestUser)
264
+ .count();
265
+
266
+ expect(count).toBeGreaterThanOrEqual(1);
267
+ });
268
+
269
+ test('count respects filters', async () => {
270
+ const entity = ctx.tracker.create();
271
+ entity.add(TestUser, { name: 'UniqueCountName', email: 'uniquecount@example.com', age: 99 });
272
+ await entity.save();
273
+
274
+ const count = await new Query()
275
+ .with(TestUser, {
276
+ filters: [Query.filter('name', FilterOp.EQ, 'UniqueCountName')]
277
+ })
278
+ .count();
279
+
280
+ expect(count).toBe(1);
281
+ });
282
+ });
283
+
284
+ describe('multiple components', () => {
285
+ test('with() multiple components finds entities with all', async () => {
286
+ const entity = ctx.tracker.create();
287
+ entity.add(TestUser, { name: 'MultiComp', email: 'multi@example.com', age: 30 });
288
+ entity.add(TestProduct, { sku: 'MULTI', name: 'Multi Product', price: 50, inStock: true });
289
+ await entity.save();
290
+
291
+ const results = await new Query()
292
+ .with(TestUser)
293
+ .with(TestProduct)
294
+ .populate()
295
+ .exec();
296
+
297
+ const found = results.find(e => e.id === entity.id);
298
+ expect(found).toBeDefined();
299
+ expect(found?.getInMemory(TestUser)).toBeDefined();
300
+ expect(found?.getInMemory(TestProduct)).toBeDefined();
301
+ });
302
+
303
+ test('without() excludes entities with component', async () => {
304
+ const withProduct = ctx.tracker.create();
305
+ withProduct.add(TestUser, { name: 'WithProduct', email: 'withprod@example.com', age: 25 });
306
+ withProduct.add(TestProduct, { sku: 'WITH', name: 'With', price: 10, inStock: true });
307
+ await withProduct.save();
308
+
309
+ const withoutProduct = ctx.tracker.create();
310
+ withoutProduct.add(TestUser, { name: 'WithoutProduct', email: 'withoutprod@example.com', age: 30 });
311
+ await withoutProduct.save();
312
+
313
+ const results = await new Query()
314
+ .with(TestUser)
315
+ .without(TestProduct)
316
+ .exec();
317
+
318
+ const hasWithProduct = results.some(e => e.id === withProduct.id);
319
+ expect(hasWithProduct).toBe(false);
320
+ });
321
+ });
322
+
323
+ describe('excludeEntityId()', () => {
324
+ test('excludes specific entity from results', async () => {
325
+ const entity1 = ctx.tracker.create();
326
+ entity1.add(TestUser, { name: 'Include', email: 'include@example.com', age: 30 });
327
+ await entity1.save();
328
+
329
+ const entity2 = ctx.tracker.create();
330
+ entity2.add(TestUser, { name: 'Exclude', email: 'exclude@example.com', age: 30 });
331
+ await entity2.save();
332
+
333
+ const results = await new Query()
334
+ .with(TestUser)
335
+ .excludeEntityId(entity2.id)
336
+ .exec();
337
+
338
+ const hasExcluded = results.some(e => e.id === entity2.id);
339
+ expect(hasExcluded).toBe(false);
340
+ });
341
+ });
342
+
343
+ describe('eagerLoadComponents()', () => {
344
+ test('preloads specified components', async () => {
345
+ const entity = ctx.tracker.create();
346
+ entity.add(TestUser, { name: 'Eager', email: 'eager@example.com', age: 30 });
347
+ entity.add(TestProduct, { sku: 'EAGER', name: 'Eager Product', price: 20, inStock: true });
348
+ await entity.save();
349
+
350
+ const results = await new Query()
351
+ .with(TestUser)
352
+ .eagerLoadComponents([TestProduct])
353
+ .exec();
354
+
355
+ const found = results.find(e => e.id === entity.id);
356
+ expect(found?.hasInMemory(TestProduct)).toBe(true);
357
+ });
358
+ });
359
+
360
+ describe('sum()', () => {
361
+ test('returns correct sum of numeric field', async () => {
362
+ // Create entities with known prices
363
+ const prices = [10, 20, 30, 40];
364
+ const expectedSum = prices.reduce((a, b) => a + b, 0); // 100
365
+
366
+ for (let i = 0; i < prices.length; i++) {
367
+ const entity = ctx.tracker.create();
368
+ entity.add(TestProduct, {
369
+ sku: `SUM_TEST_${i}_${Date.now()}`,
370
+ name: `Sum Product ${i}`,
371
+ price: prices[i]!,
372
+ inStock: true
373
+ });
374
+ await entity.save();
375
+ }
376
+
377
+ const sum = await new Query()
378
+ .with(TestProduct, {
379
+ filters: [Query.filter('sku', FilterOp.LIKE, 'SUM_TEST_%')]
380
+ })
381
+ .sum(TestProduct, 'price');
382
+
383
+ expect(sum).toBe(expectedSum);
384
+ });
385
+
386
+ test('returns 0 when no entities match', async () => {
387
+ const sum = await new Query()
388
+ .with(TestProduct, {
389
+ filters: [Query.filter('sku', FilterOp.EQ, 'NONEXISTENT_SKU_12345')]
390
+ })
391
+ .sum(TestProduct, 'price');
392
+
393
+ expect(sum).toBe(0);
394
+ });
395
+
396
+ test('sum works with filters', async () => {
397
+ // Create entities - some in stock, some not
398
+ const entity1 = ctx.tracker.create();
399
+ entity1.add(TestProduct, {
400
+ sku: `SUM_FILTER_1_${Date.now()}`,
401
+ name: 'In Stock',
402
+ price: 50,
403
+ inStock: true
404
+ });
405
+ await entity1.save();
406
+
407
+ const entity2 = ctx.tracker.create();
408
+ entity2.add(TestProduct, {
409
+ sku: `SUM_FILTER_2_${Date.now()}`,
410
+ name: 'Out of Stock',
411
+ price: 100,
412
+ inStock: false
413
+ });
414
+ await entity2.save();
415
+
416
+ // Sum only in-stock items
417
+ const sum = await new Query()
418
+ .with(TestProduct, {
419
+ filters: [
420
+ Query.filter('sku', FilterOp.LIKE, 'SUM_FILTER_%'),
421
+ Query.filter('inStock', FilterOp.EQ, true)
422
+ ]
423
+ })
424
+ .sum(TestProduct, 'price');
425
+
426
+ expect(sum).toBe(50);
427
+ });
428
+ });
429
+
430
+ describe('average()', () => {
431
+ test('returns correct average of numeric field', async () => {
432
+ // Create entities with known prices
433
+ const prices = [10, 20, 30, 40];
434
+ const expectedAvg = prices.reduce((a, b) => a + b, 0) / prices.length; // 25
435
+
436
+ for (let i = 0; i < prices.length; i++) {
437
+ const entity = ctx.tracker.create();
438
+ entity.add(TestProduct, {
439
+ sku: `AVG_TEST_${i}_${Date.now()}`,
440
+ name: `Avg Product ${i}`,
441
+ price: prices[i]!,
442
+ inStock: true
443
+ });
444
+ await entity.save();
445
+ }
446
+
447
+ const avg = await new Query()
448
+ .with(TestProduct, {
449
+ filters: [Query.filter('sku', FilterOp.LIKE, 'AVG_TEST_%')]
450
+ })
451
+ .average(TestProduct, 'price');
452
+
453
+ expect(avg).toBe(expectedAvg);
454
+ });
455
+
456
+ test('returns 0 when no entities match', async () => {
457
+ const avg = await new Query()
458
+ .with(TestProduct, {
459
+ filters: [Query.filter('sku', FilterOp.EQ, 'NONEXISTENT_SKU_67890')]
460
+ })
461
+ .average(TestProduct, 'price');
462
+
463
+ expect(avg).toBe(0);
464
+ });
465
+
466
+ test('average handles decimal results', async () => {
467
+ // Create entities with prices that result in decimal average
468
+ const prices = [10, 20, 30]; // avg = 20
469
+
470
+ for (let i = 0; i < prices.length; i++) {
471
+ const entity = ctx.tracker.create();
472
+ entity.add(TestProduct, {
473
+ sku: `AVG_DEC_${i}_${Date.now()}`,
474
+ name: `Decimal Avg ${i}`,
475
+ price: prices[i]!,
476
+ inStock: true
477
+ });
478
+ await entity.save();
479
+ }
480
+
481
+ const avg = await new Query()
482
+ .with(TestProduct, {
483
+ filters: [Query.filter('sku', FilterOp.LIKE, 'AVG_DEC_%')]
484
+ })
485
+ .average(TestProduct, 'price');
486
+
487
+ expect(avg).toBe(20);
488
+ });
489
+
490
+ test('average works with filters', async () => {
491
+ // Create entities - some expensive, some cheap
492
+ const entity1 = ctx.tracker.create();
493
+ entity1.add(TestProduct, {
494
+ sku: `AVG_FILTER_CHEAP_${Date.now()}`,
495
+ name: 'Cheap Product',
496
+ price: 10,
497
+ inStock: true
498
+ });
499
+ await entity1.save();
500
+
501
+ const entity2 = ctx.tracker.create();
502
+ entity2.add(TestProduct, {
503
+ sku: `AVG_FILTER_EXPENSIVE_${Date.now()}`,
504
+ name: 'Expensive Product',
505
+ price: 1000,
506
+ inStock: true
507
+ });
508
+ await entity2.save();
509
+
510
+ // Average only cheap items (price < 100)
511
+ const avg = await new Query()
512
+ .with(TestProduct, {
513
+ filters: [
514
+ Query.filter('sku', FilterOp.LIKE, 'AVG_FILTER_%'),
515
+ Query.filter('price', FilterOp.LT, 100)
516
+ ]
517
+ })
518
+ .average(TestProduct, 'price');
519
+
520
+ expect(avg).toBe(10);
521
+ });
522
+ });
523
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * PGlite wrapper script for zero-infrastructure testing.
3
+ *
4
+ * Starts an in-memory PostgreSQL via PGlite Socket, then spawns
5
+ * `bun test` with the correct env vars already set at the process level.
6
+ * This avoids all preload ordering issues.
7
+ *
8
+ * Usage:
9
+ * bun tests/pglite-setup.ts [test-dirs...]
10
+ * bun tests/pglite-setup.ts tests/unit/
11
+ * bun tests/pglite-setup.ts tests/unit tests/integration tests/graphql
12
+ */
13
+
14
+ import { PGlite } from '@electric-sql/pglite';
15
+ import { PGLiteSocketServer } from '@electric-sql/pglite-socket';
16
+ import { spawn } from 'child_process';
17
+
18
+ const PORT = 54321;
19
+
20
+ console.log('[pglite] Starting in-memory PostgreSQL...');
21
+ const pg = new PGlite();
22
+ await pg.waitReady;
23
+
24
+ const server = new PGLiteSocketServer({ db: pg, port: PORT });
25
+ await server.start();
26
+ console.log(`[pglite] Socket server running on port ${PORT}`);
27
+
28
+ // Test dirs from CLI args, default to unit + integration + graphql
29
+ const testDirs = process.argv.slice(2);
30
+ if (testDirs.length === 0) {
31
+ testDirs.push('tests/unit', 'tests/integration', 'tests/graphql');
32
+ }
33
+
34
+ const proc = spawn('bun', ['test', ...testDirs], {
35
+ env: {
36
+ ...process.env,
37
+ USE_PGLITE: 'true',
38
+ POSTGRES_HOST: 'localhost',
39
+ POSTGRES_PORT: String(PORT),
40
+ POSTGRES_USER: 'postgres',
41
+ POSTGRES_PASSWORD: 'postgres',
42
+ POSTGRES_DB: 'postgres',
43
+ POSTGRES_MAX_CONNECTIONS: '1',
44
+ },
45
+ stdio: 'inherit',
46
+ cwd: process.cwd(),
47
+ });
48
+
49
+ proc.on('exit', async (code) => {
50
+ console.log('[pglite] Stopping server...');
51
+ try { await server.stop(); } catch {}
52
+ try { await pg.close(); } catch {}
53
+ process.exit(code ?? 1);
54
+ });
55
+
56
+ proc.on('error', async (err) => {
57
+ console.error('[pglite] Failed to spawn bun test:', err);
58
+ try { await server.stop(); } catch {}
59
+ try { await pg.close(); } catch {}
60
+ process.exit(1);
61
+ });