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.
- package/.claude/settings.local.json +47 -0
- package/.claude/skills/update-memory.md +74 -0
- package/.prettierrc +4 -0
- package/.serena/memories/architectural-decision-no-dependency-injection.md +76 -0
- package/.serena/memories/architecture.md +154 -0
- package/.serena/memories/cache-interface-refactoring-2026-01-24.md +165 -0
- package/.serena/memories/code_style_and_conventions.md +76 -0
- package/.serena/memories/project_overview.md +43 -0
- package/.serena/memories/schema-dsl-plan.md +107 -0
- package/.serena/memories/suggested_commands.md +80 -0
- package/.serena/memories/typescript-compilation-status.md +54 -0
- package/.serena/project.yml +114 -0
- package/TODO.md +1 -7
- package/bun.lock +150 -4
- package/bunfig.toml +10 -0
- package/config/cache.config.ts +77 -0
- package/config/upload.config.ts +4 -5
- package/core/App.ts +870 -123
- package/core/ArcheType.ts +2268 -377
- package/core/BatchLoader.ts +181 -71
- package/core/Config.ts +153 -0
- package/core/Decorators.ts +4 -1
- package/core/Entity.ts +621 -92
- package/core/EntityHookManager.ts +1 -1
- package/core/EntityInterface.ts +3 -1
- package/core/EntityManager.ts +1 -13
- package/core/ErrorHandler.ts +8 -2
- package/core/Logger.ts +9 -0
- package/core/Middleware.ts +34 -0
- package/core/RequestContext.ts +5 -1
- package/core/RequestLoaders.ts +227 -93
- package/core/SchedulerManager.ts +193 -52
- package/core/cache/CacheAnalytics.ts +399 -0
- package/core/cache/CacheFactory.ts +145 -0
- package/core/cache/CacheManager.ts +520 -0
- package/core/cache/CacheProvider.ts +34 -0
- package/core/cache/CacheWarmer.ts +157 -0
- package/core/cache/CompressionUtils.ts +110 -0
- package/core/cache/MemoryCache.ts +251 -0
- package/core/cache/MultiLevelCache.ts +180 -0
- package/core/cache/NoOpCache.ts +53 -0
- package/core/cache/RedisCache.ts +464 -0
- package/core/cache/TTLStrategy.ts +254 -0
- package/core/cache/index.ts +6 -0
- package/core/components/BaseComponent.ts +120 -0
- package/core/{ComponentRegistry.ts → components/ComponentRegistry.ts} +148 -54
- package/core/components/Decorators.ts +88 -0
- package/core/components/Interfaces.ts +7 -0
- package/core/components/index.ts +5 -0
- package/core/decorators/EntityHooks.ts +0 -3
- package/core/decorators/IndexedField.ts +26 -0
- package/core/decorators/ScheduledTask.ts +0 -47
- package/core/events/EntityLifecycleEvents.ts +1 -1
- package/core/health.ts +112 -0
- package/core/metadata/definitions/ArcheType.ts +14 -0
- package/core/metadata/definitions/Component.ts +9 -0
- package/core/metadata/definitions/gqlObject.ts +1 -1
- package/core/metadata/index.ts +42 -1
- package/core/metadata/metadata-storage.ts +28 -2
- package/core/middleware/AccessLog.ts +59 -0
- package/core/middleware/RequestId.ts +38 -0
- package/core/middleware/SecurityHeaders.ts +62 -0
- package/core/middleware/index.ts +3 -0
- package/core/scheduler/DistributedLock.ts +266 -0
- package/core/scheduler/index.ts +15 -0
- package/core/validateEnv.ts +92 -0
- package/database/DatabaseHelper.ts +416 -40
- package/database/IndexingStrategy.ts +342 -0
- package/database/PreparedStatementCache.ts +226 -0
- package/database/index.ts +32 -7
- package/database/sqlHelpers.ts +14 -2
- package/endpoints/archetypes.ts +362 -0
- package/endpoints/components.ts +58 -0
- package/endpoints/entity.ts +80 -0
- package/endpoints/index.ts +27 -0
- package/endpoints/query.ts +93 -0
- package/endpoints/stats.ts +76 -0
- package/endpoints/tables.ts +212 -0
- package/endpoints/types.ts +155 -0
- package/gql/ArchetypeOperations.ts +32 -86
- package/gql/Generator.ts +27 -315
- package/gql/GeneratorV2.ts +37 -0
- package/gql/builders/InputTypeBuilder.ts +99 -0
- package/gql/builders/ResolverBuilder.ts +234 -0
- package/gql/builders/TypeDefBuilder.ts +105 -0
- package/gql/builders/index.ts +3 -0
- package/gql/decorators/Upload.ts +1 -1
- package/gql/depthLimit.ts +85 -0
- package/gql/graph/GraphNode.ts +224 -0
- package/gql/graph/SchemaGraph.ts +278 -0
- package/gql/helpers.ts +8 -2
- package/gql/index.ts +56 -4
- package/gql/middleware.ts +79 -0
- package/gql/orchestration/GraphQLSchemaOrchestrator.ts +241 -0
- package/gql/orchestration/index.ts +1 -0
- package/gql/scanner/ServiceScanner.ts +347 -0
- package/gql/schema/index.ts +458 -0
- package/gql/strategies/TypeGenerationStrategy.ts +329 -0
- package/gql/types.ts +1 -0
- package/gql/utils/TypeSignature.ts +220 -0
- package/gql/utils/index.ts +1 -0
- package/gql/visitors/ArchetypePreprocessorVisitor.ts +80 -0
- package/gql/visitors/DeduplicationVisitor.ts +82 -0
- package/gql/visitors/GraphVisitor.ts +78 -0
- package/gql/visitors/ResolverGeneratorVisitor.ts +122 -0
- package/gql/visitors/SchemaGeneratorVisitor.ts +851 -0
- package/gql/visitors/TypeCollectorVisitor.ts +79 -0
- package/gql/visitors/VisitorComposer.ts +96 -0
- package/gql/visitors/index.ts +7 -0
- package/package.json +59 -37
- package/plugins/index.ts +2 -2
- package/query/CTENode.ts +97 -0
- package/query/ComponentInclusionNode.ts +689 -0
- package/query/FilterBuilder.ts +127 -0
- package/query/FilterBuilderRegistry.ts +202 -0
- package/query/OrNode.ts +517 -0
- package/query/OrQuery.ts +42 -0
- package/query/Query.ts +1022 -0
- package/query/QueryContext.ts +170 -0
- package/query/QueryDAG.ts +122 -0
- package/query/QueryNode.ts +65 -0
- package/query/SourceNode.ts +53 -0
- package/query/builders/FullTextSearchBuilder.ts +236 -0
- package/query/index.ts +21 -0
- package/scheduler/index.ts +40 -8
- package/service/Service.ts +2 -1
- package/service/ServiceRegistry.ts +6 -5
- package/{core/storage → storage}/LocalStorageProvider.ts +2 -2
- package/storage/S3StorageProvider.ts +316 -0
- package/{core/storage → storage}/StorageProvider.ts +7 -3
- package/studio/bun.lock +482 -0
- package/studio/index.html +13 -0
- package/studio/package.json +39 -0
- package/studio/postcss.config.js +6 -0
- package/studio/src/components/DataTable.tsx +211 -0
- package/studio/src/components/Layout.tsx +13 -0
- package/studio/src/components/PageContainer.tsx +9 -0
- package/studio/src/components/PageHeader.tsx +13 -0
- package/studio/src/components/SearchBar.tsx +57 -0
- package/studio/src/components/Sidebar.tsx +294 -0
- package/studio/src/components/ui/button.tsx +56 -0
- package/studio/src/components/ui/checkbox.tsx +26 -0
- package/studio/src/components/ui/input.tsx +25 -0
- package/studio/src/hooks/useDataTable.ts +131 -0
- package/studio/src/index.css +36 -0
- package/studio/src/lib/api.ts +186 -0
- package/studio/src/lib/utils.ts +13 -0
- package/studio/src/main.tsx +17 -0
- package/studio/src/pages/ArcheType.tsx +239 -0
- package/studio/src/pages/Components.tsx +124 -0
- package/studio/src/pages/EntityInspector.tsx +302 -0
- package/studio/src/pages/QueryRunner.tsx +246 -0
- package/studio/src/pages/Table.tsx +94 -0
- package/studio/src/pages/Welcome.tsx +241 -0
- package/studio/src/routes.tsx +45 -0
- package/studio/src/store/archeTypeSettings.ts +30 -0
- package/studio/src/store/studio.ts +65 -0
- package/studio/src/utils/columnHelpers.tsx +114 -0
- package/studio/studio-instructions.md +81 -0
- package/studio/tailwind.config.js +77 -0
- package/studio/tsconfig.json +24 -0
- package/studio/utils.ts +54 -0
- package/studio/vite.config.js +19 -0
- package/swagger/generator.ts +1 -1
- package/tests/e2e/http.test.ts +126 -0
- package/tests/fixtures/archetypes/TestUserArchetype.ts +21 -0
- package/tests/fixtures/components/TestOrder.ts +23 -0
- package/tests/fixtures/components/TestProduct.ts +23 -0
- package/tests/fixtures/components/TestUser.ts +20 -0
- package/tests/fixtures/components/index.ts +6 -0
- package/tests/graphql/SchemaGeneration.test.ts +90 -0
- package/tests/graphql/builders/ResolverBuilder.test.ts +223 -0
- package/tests/graphql/builders/TypeDefBuilder.test.ts +153 -0
- package/tests/integration/archetype/ArcheType.persistence.test.ts +241 -0
- package/tests/integration/cache/CacheInvalidation.test.ts +259 -0
- package/tests/integration/entity/Entity.persistence.test.ts +333 -0
- package/tests/integration/query/Query.exec.test.ts +523 -0
- package/tests/pglite-setup.ts +61 -0
- package/tests/setup.ts +164 -0
- package/tests/stress/BenchmarkRunner.ts +203 -0
- package/tests/stress/DataSeeder.ts +190 -0
- package/tests/stress/StressTestReporter.ts +229 -0
- package/tests/stress/cursor-perf-test.ts +171 -0
- package/tests/stress/fixtures/StressTestComponents.ts +58 -0
- package/tests/stress/index.ts +7 -0
- package/tests/stress/scenarios/query-benchmarks.test.ts +285 -0
- package/tests/unit/BatchLoader.test.ts +82 -0
- package/tests/unit/archetype/ArcheType.test.ts +107 -0
- package/tests/unit/cache/CacheManager.test.ts +347 -0
- package/tests/unit/cache/MemoryCache.test.ts +260 -0
- package/tests/unit/cache/RedisCache.test.ts +411 -0
- package/tests/unit/entity/Entity.components.test.ts +244 -0
- package/tests/unit/entity/Entity.test.ts +345 -0
- package/tests/unit/gql/depthLimit.test.ts +203 -0
- package/tests/unit/gql/operationMiddleware.test.ts +293 -0
- package/tests/unit/health/Health.test.ts +129 -0
- package/tests/unit/middleware/AccessLog.test.ts +37 -0
- package/tests/unit/middleware/Middleware.test.ts +98 -0
- package/tests/unit/middleware/RequestId.test.ts +54 -0
- package/tests/unit/middleware/SecurityHeaders.test.ts +66 -0
- package/tests/unit/query/FilterBuilder.test.ts +111 -0
- package/tests/unit/query/Query.test.ts +308 -0
- package/tests/unit/scheduler/DistributedLock.test.ts +274 -0
- package/tests/unit/schema/schema-integration.test.ts +426 -0
- package/tests/unit/schema/schema.test.ts +580 -0
- package/tests/unit/storage/S3StorageProvider.test.ts +571 -0
- package/tests/unit/upload/RestUpload.test.ts +267 -0
- package/tests/unit/validateEnv.test.ts +82 -0
- package/tests/utils/entity-tracker.ts +57 -0
- package/tests/utils/index.ts +13 -0
- package/tests/utils/test-context.ts +149 -0
- package/tsconfig.json +5 -1
- package/types/archetype.types.ts +6 -0
- package/types/hooks.types.ts +1 -1
- package/types/query.types.ts +110 -0
- package/types/scheduler.types.ts +68 -7
- package/types/upload.types.ts +1 -0
- package/{core → upload}/FileValidator.ts +10 -1
- package/upload/RestUpload.ts +130 -0
- package/{core/components → upload}/UploadComponent.ts +11 -11
- package/{core → upload}/UploadManager.ts +3 -3
- package/upload/index.ts +23 -7
- package/utils/UploadHelper.ts +27 -6
- package/utils/cronParser.ts +16 -6
- package/.github/workflows/deploy-docs.yml +0 -57
- package/core/Components.ts +0 -202
- package/core/EntityCache.ts +0 -15
- package/core/Query.ts +0 -880
- package/docs/README.md +0 -149
- package/docs/_coverpage.md +0 -36
- package/docs/_sidebar.md +0 -23
- package/docs/api/core.md +0 -568
- package/docs/api/hooks.md +0 -554
- package/docs/api/index.md +0 -222
- package/docs/api/query.md +0 -678
- package/docs/api/service.md +0 -744
- package/docs/core-concepts/archetypes.md +0 -512
- package/docs/core-concepts/components.md +0 -498
- package/docs/core-concepts/entity.md +0 -314
- package/docs/core-concepts/hooks.md +0 -683
- package/docs/core-concepts/query.md +0 -588
- package/docs/core-concepts/services.md +0 -647
- package/docs/examples/code-examples.md +0 -425
- package/docs/getting-started.md +0 -337
- package/docs/index.html +0 -97
- package/tests/bench/insert.bench.ts +0 -60
- package/tests/bench/relations.bench.ts +0 -270
- package/tests/bench/sorting.bench.ts +0 -416
- package/tests/component-hooks-simple.test.ts +0 -117
- package/tests/component-hooks.test.ts +0 -1461
- package/tests/component.test.ts +0 -339
- package/tests/errorHandling.test.ts +0 -155
- package/tests/hooks.test.ts +0 -667
- package/tests/query-sorting.test.ts +0 -101
- package/tests/query.test.ts +0 -81
- package/tests/relations.test.ts +0 -170
- 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
|
+
});
|