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,111 @@
1
+ /**
2
+ * Unit tests for FilterBuilder utility functions
3
+ */
4
+ import { describe, test, expect } from 'bun:test';
5
+ import { buildJSONPath, composeFilters, type FilterBuilder, type FilterResult } from '../../../query/FilterBuilder';
6
+
7
+ describe('FilterBuilder', () => {
8
+ describe('buildJSONPath()', () => {
9
+ test('builds simple field path', () => {
10
+ const result = buildJSONPath('name', 'c');
11
+ expect(result).toBe("c.data->>'name'");
12
+ });
13
+
14
+ test('builds nested field path', () => {
15
+ const result = buildJSONPath('location.latitude', 'c');
16
+ expect(result).toBe("c.data->'location'->>'latitude'");
17
+ });
18
+
19
+ test('builds deeply nested path', () => {
20
+ const result = buildJSONPath('device.location.coordinates.latitude', 'c');
21
+ expect(result).toBe("c.data->'device'->'location'->'coordinates'->>'latitude'");
22
+ });
23
+
24
+ test('uses provided alias', () => {
25
+ const result = buildJSONPath('field', 'comp');
26
+ expect(result).toBe("comp.data->>'field'");
27
+ });
28
+ });
29
+
30
+ describe('composeFilters()', () => {
31
+ test('throws for empty array', () => {
32
+ expect(() => composeFilters([])).toThrow('Cannot compose empty array of filter builders');
33
+ });
34
+
35
+ test('composes single builder', () => {
36
+ const mockBuilder: FilterBuilder = () => ({
37
+ sql: 'field = $1',
38
+ addedParams: 1
39
+ });
40
+
41
+ const composed = composeFilters([mockBuilder]);
42
+ const result = composed(
43
+ { field: 'test', operator: 'EQ', value: 'value' },
44
+ 'c',
45
+ {} as any
46
+ );
47
+
48
+ expect(result.sql).toBe('(field = $1)');
49
+ expect(result.addedParams).toBe(1);
50
+ });
51
+
52
+ test('composes multiple builders with AND', () => {
53
+ const builder1: FilterBuilder = () => ({
54
+ sql: 'field1 = $1',
55
+ addedParams: 1
56
+ });
57
+ const builder2: FilterBuilder = () => ({
58
+ sql: 'field2 > $2',
59
+ addedParams: 1
60
+ });
61
+
62
+ const composed = composeFilters([builder1, builder2]);
63
+ const result = composed(
64
+ { field: 'test', operator: 'EQ', value: 'value' },
65
+ 'c',
66
+ {} as any
67
+ );
68
+
69
+ expect(result.sql).toBe('(field1 = $1) AND (field2 > $2)');
70
+ expect(result.addedParams).toBe(2);
71
+ });
72
+
73
+ test('handles empty SQL from builder', () => {
74
+ const builder1: FilterBuilder = () => ({
75
+ sql: 'field1 = $1',
76
+ addedParams: 1
77
+ });
78
+ const builder2: FilterBuilder = () => ({
79
+ sql: '',
80
+ addedParams: 0
81
+ });
82
+
83
+ const composed = composeFilters([builder1, builder2]);
84
+ const result = composed(
85
+ { field: 'test', operator: 'EQ', value: 'value' },
86
+ 'c',
87
+ {} as any
88
+ );
89
+
90
+ expect(result.sql).toBe('(field1 = $1)');
91
+ expect(result.addedParams).toBe(1);
92
+ });
93
+
94
+ test('handles whitespace-only SQL', () => {
95
+ const builder: FilterBuilder = () => ({
96
+ sql: ' ',
97
+ addedParams: 0
98
+ });
99
+
100
+ const composed = composeFilters([builder]);
101
+ const result = composed(
102
+ { field: 'test', operator: 'EQ', value: 'value' },
103
+ 'c',
104
+ {} as any
105
+ );
106
+
107
+ expect(result.sql).toBe('');
108
+ expect(result.addedParams).toBe(0);
109
+ });
110
+ });
111
+ });
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Unit tests for Query class
3
+ * Tests query builder methods and structure
4
+ */
5
+ import { describe, test, expect, beforeAll } from 'bun:test';
6
+ import { Query, FilterOp } from '../../../query/Query';
7
+ import { TestUser, TestProduct } from '../../fixtures/components';
8
+ import { ensureComponentsRegistered } from '../../utils';
9
+
10
+ describe('Query', () => {
11
+ beforeAll(async () => {
12
+ await ensureComponentsRegistered(TestUser, TestProduct);
13
+ });
14
+
15
+ describe('constructor', () => {
16
+ test('creates a new query instance', () => {
17
+ const query = new Query();
18
+ expect(query).toBeDefined();
19
+ });
20
+
21
+ test('creates query with transaction', () => {
22
+ // Query accepts optional transaction parameter
23
+ const query = new Query();
24
+ expect(query).toBeDefined();
25
+ });
26
+ });
27
+
28
+ describe('findById()', () => {
29
+ test('sets up query to find by ID', () => {
30
+ const query = new Query();
31
+ const result = query.findById('test-id-123');
32
+ expect(result).toBe(query); // Returns this for chaining
33
+ });
34
+
35
+ test('throws for empty string id', () => {
36
+ const query = new Query();
37
+ expect(() => query.findById('')).toThrow();
38
+ });
39
+
40
+ test('throws for whitespace id', () => {
41
+ const query = new Query();
42
+ expect(() => query.findById(' ')).toThrow();
43
+ });
44
+ });
45
+
46
+ describe('with()', () => {
47
+ test('adds component requirement to query', () => {
48
+ const query = new Query();
49
+ const result = query.with(TestUser);
50
+ // with() returns same instance for chaining (type changes for type accumulation)
51
+ expect(result as any).toBe(query as any);
52
+ });
53
+
54
+ test('allows chaining multiple with() calls', () => {
55
+ const query = new Query();
56
+ const result = query.with(TestUser).with(TestProduct);
57
+ expect(result as any).toBe(query as any);
58
+ });
59
+
60
+ test('accepts component with filters', () => {
61
+ const query = new Query();
62
+ const result = query.with(TestUser, {
63
+ filters: [{ field: 'name', operator: FilterOp.EQ, value: 'John' }]
64
+ });
65
+ expect(result as any).toBe(query as any);
66
+ });
67
+
68
+ test('accepts array of components with filters', () => {
69
+ const query = new Query();
70
+ const result = query.with([
71
+ { component: TestUser, filters: [{ field: 'age', operator: FilterOp.GT, value: 18 }] },
72
+ { component: TestProduct, filters: [{ field: 'price', operator: FilterOp.LT, value: 100 }] }
73
+ ]);
74
+ expect(result).toBe(query);
75
+ });
76
+ });
77
+
78
+ describe('without()', () => {
79
+ test('excludes entities with specified component', () => {
80
+ const query = new Query();
81
+ const result = query.with(TestUser).without(TestProduct);
82
+ expect(result as any).toBe(query as any);
83
+ });
84
+ });
85
+
86
+ describe('excludeEntityId()', () => {
87
+ test('excludes specific entity from results', () => {
88
+ const query = new Query();
89
+ const result = query.excludeEntityId('entity-to-exclude');
90
+ expect(result).toBe(query);
91
+ });
92
+ });
93
+
94
+ describe('populate()', () => {
95
+ test('enables component population', () => {
96
+ const query = new Query();
97
+ const result = query.with(TestUser).populate();
98
+ expect(result as any).toBe(query as any);
99
+ });
100
+ });
101
+
102
+ describe('eagerLoadComponents()', () => {
103
+ test('sets up eager loading for components', () => {
104
+ const query = new Query();
105
+ const result = query.with(TestUser).eagerLoadComponents([TestUser, TestProduct]);
106
+ expect(result as any).toBe(query as any);
107
+ });
108
+ });
109
+
110
+ describe('eagerLoad()', () => {
111
+ test('is an alias for eagerLoadComponents', () => {
112
+ const query = new Query();
113
+ const result = query.with(TestUser).eagerLoad([TestUser]);
114
+ expect(result as any).toBe(query as any);
115
+ });
116
+ });
117
+
118
+ describe('take()', () => {
119
+ test('sets limit on query results', () => {
120
+ const query = new Query();
121
+ const result = query.with(TestUser).take(10);
122
+ expect(result as any).toBe(query as any);
123
+ });
124
+ });
125
+
126
+ describe('offset()', () => {
127
+ test('sets offset for pagination', () => {
128
+ const query = new Query();
129
+ const result = query.with(TestUser).offset(20);
130
+ expect(result as any).toBe(query as any);
131
+ });
132
+ });
133
+
134
+ describe('sortBy()', () => {
135
+ test('sets sort order', () => {
136
+ const query = new Query();
137
+ const result = query.with(TestUser).sortBy(TestUser, 'name', 'ASC');
138
+ expect(result as any).toBe(query as any);
139
+ });
140
+
141
+ test('accepts DESC direction', () => {
142
+ const query = new Query();
143
+ const result = query.with(TestUser).sortBy(TestUser, 'age', 'DESC');
144
+ expect(result as any).toBe(query as any);
145
+ });
146
+
147
+ test('accepts nullsFirst option', () => {
148
+ const query = new Query();
149
+ const result = query.with(TestUser).sortBy(TestUser, 'bio', 'ASC', true);
150
+ expect(result as any).toBe(query as any);
151
+ });
152
+
153
+ test('throws if component not in query', () => {
154
+ const query = new Query();
155
+ expect(() => query.sortBy(TestUser, 'name')).toThrow();
156
+ });
157
+ });
158
+
159
+ describe('debugMode()', () => {
160
+ test('enables debug mode', () => {
161
+ const query = new Query();
162
+ const result = query.debugMode(true);
163
+ expect(result).toBe(query);
164
+ });
165
+
166
+ test('can disable debug mode', () => {
167
+ const query = new Query();
168
+ const result = query.debugMode(false);
169
+ expect(result).toBe(query);
170
+ });
171
+ });
172
+
173
+ describe('noCache()', () => {
174
+ test('bypasses prepared statement cache by default', () => {
175
+ const query = new Query();
176
+ const result = query.noCache();
177
+ expect(result).toBe(query);
178
+ });
179
+
180
+ test('accepts cache options', () => {
181
+ const query = new Query();
182
+ const result = query.noCache({ preparedStatement: true, component: true });
183
+ expect(result).toBe(query);
184
+ });
185
+ });
186
+
187
+ describe('filter()', () => {
188
+ test('creates filter object', () => {
189
+ const filter = Query.filter('name', FilterOp.EQ, 'John');
190
+ expect(filter.field).toBe('name');
191
+ expect(filter.operator).toBe(FilterOp.EQ);
192
+ expect(filter.value).toBe('John');
193
+ });
194
+
195
+ test('throws for empty string value', () => {
196
+ expect(() => Query.filter('name', FilterOp.EQ, '')).toThrow();
197
+ });
198
+
199
+ test('throws for whitespace value', () => {
200
+ expect(() => Query.filter('name', FilterOp.EQ, ' ')).toThrow();
201
+ });
202
+ });
203
+
204
+ describe('typedFilter()', () => {
205
+ test('creates typed filter object', () => {
206
+ const filter = Query.typedFilter(TestUser, 'name', FilterOp.EQ, 'John');
207
+ expect(filter.field).toBe('name');
208
+ expect(filter.operator).toBe(FilterOp.EQ);
209
+ expect(filter.value).toBe('John');
210
+ });
211
+ });
212
+
213
+ describe('filters()', () => {
214
+ test('creates filter options from multiple filters', () => {
215
+ const filter1 = Query.filter('name', FilterOp.EQ, 'John');
216
+ const filter2 = Query.filter('age', FilterOp.GT, 18);
217
+ const options = Query.filters(filter1, filter2);
218
+
219
+ expect(options.filters).toBeDefined();
220
+ expect(options.filters?.length).toBe(2);
221
+ });
222
+ });
223
+
224
+ describe('FilterOp', () => {
225
+ test('has all expected operators', () => {
226
+ expect(FilterOp.EQ).toBeDefined();
227
+ expect(FilterOp.NEQ).toBeDefined();
228
+ expect(FilterOp.GT).toBeDefined();
229
+ expect(FilterOp.GTE).toBeDefined();
230
+ expect(FilterOp.LT).toBeDefined();
231
+ expect(FilterOp.LTE).toBeDefined();
232
+ expect(FilterOp.LIKE).toBeDefined();
233
+ expect(FilterOp.IN).toBeDefined();
234
+ expect(FilterOp.NOT_IN).toBeDefined();
235
+ });
236
+ });
237
+
238
+ describe('chaining', () => {
239
+ test('supports full query chain', () => {
240
+ const query = new Query()
241
+ .with(TestUser, { filters: [Query.filter('age', FilterOp.GTE, 18)] })
242
+ .with(TestProduct)
243
+ .without(TestProduct)
244
+ .take(10)
245
+ .offset(0)
246
+ .sortBy(TestUser, 'name')
247
+ .populate()
248
+ .debugMode(false)
249
+ .noCache();
250
+
251
+ expect(query).toBeDefined();
252
+ });
253
+ });
254
+
255
+ describe('sum()', () => {
256
+ test('throws if component not in query', async () => {
257
+ const query = new Query();
258
+ // TestProduct not added via .with()
259
+ await expect(query.sum(TestProduct, 'price')).rejects.toThrow(
260
+ /not included in the query/
261
+ );
262
+ });
263
+
264
+ test('throws if component is not registered', async () => {
265
+ class UnregisteredComponent {
266
+ value!: number;
267
+ }
268
+ const query = new Query();
269
+ // @ts-expect-error - Testing with unregistered component
270
+ await expect(query.sum(UnregisteredComponent, 'value')).rejects.toThrow(
271
+ /not registered/
272
+ );
273
+ });
274
+
275
+ test('returns a Promise', () => {
276
+ const query = new Query().with(TestProduct);
277
+ const result = query.sum(TestProduct, 'price');
278
+ expect(result).toBeInstanceOf(Promise);
279
+ });
280
+ });
281
+
282
+ describe('average()', () => {
283
+ test('throws if component not in query', async () => {
284
+ const query = new Query();
285
+ // TestProduct not added via .with()
286
+ await expect(query.average(TestProduct, 'price')).rejects.toThrow(
287
+ /not included in the query/
288
+ );
289
+ });
290
+
291
+ test('throws if component is not registered', async () => {
292
+ class UnregisteredComponent {
293
+ value!: number;
294
+ }
295
+ const query = new Query();
296
+ // @ts-expect-error - Testing with unregistered component
297
+ await expect(query.average(UnregisteredComponent, 'value')).rejects.toThrow(
298
+ /not registered/
299
+ );
300
+ });
301
+
302
+ test('returns a Promise', () => {
303
+ const query = new Query().with(TestProduct);
304
+ const result = query.average(TestProduct, 'price');
305
+ expect(result).toBeInstanceOf(Promise);
306
+ });
307
+ });
308
+ });
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Unit tests for DistributedLock
3
+ * Tests PostgreSQL advisory lock-based distributed locking functionality
4
+ *
5
+ * Note: These tests require a PostgreSQL database connection
6
+ */
7
+ import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from 'bun:test';
8
+ import { DistributedLock, resetDistributedLock, DEFAULT_LOCK_CONFIG } from '../../../core/scheduler/DistributedLock';
9
+
10
+ describe('DistributedLock', () => {
11
+ let lock: DistributedLock;
12
+
13
+ beforeEach(() => {
14
+ // Reset singleton before each test
15
+ resetDistributedLock();
16
+ lock = new DistributedLock({
17
+ enabled: true,
18
+ enableLogging: false,
19
+ lockTimeout: 0,
20
+ retryInterval: 50,
21
+ });
22
+ });
23
+
24
+ afterEach(async () => {
25
+ // Release all locks and reset
26
+ await lock.releaseAll();
27
+ resetDistributedLock();
28
+ });
29
+
30
+ describe('constructor', () => {
31
+ test('creates lock with default config', () => {
32
+ const defaultLock = new DistributedLock();
33
+ expect(defaultLock).toBeDefined();
34
+ expect(defaultLock.getConfig().enabled).toBe(DEFAULT_LOCK_CONFIG.enabled);
35
+ expect(defaultLock.getConfig().lockKeyPrefix).toBe(DEFAULT_LOCK_CONFIG.lockKeyPrefix);
36
+ });
37
+
38
+ test('creates lock with custom config', () => {
39
+ const customLock = new DistributedLock({
40
+ enabled: false,
41
+ lockKeyPrefix: 0x12345678,
42
+ lockTimeout: 5000,
43
+ });
44
+ const config = customLock.getConfig();
45
+ expect(config.enabled).toBe(false);
46
+ expect(config.lockKeyPrefix).toBe(0x12345678);
47
+ expect(config.lockTimeout).toBe(5000);
48
+ });
49
+ });
50
+
51
+ describe('tryAcquire()', () => {
52
+ test('acquires lock for new task', async () => {
53
+ const result = await lock.tryAcquire('test-task-1');
54
+
55
+ expect(result.acquired).toBe(true);
56
+ expect(result.taskId).toBe('test-task-1');
57
+ expect(result.lockKey).toBeDefined();
58
+ expect(typeof result.lockKey).toBe('bigint');
59
+ });
60
+
61
+ test('generates consistent lock keys for same task', async () => {
62
+ const lock2 = new DistributedLock({ enabled: true, enableLogging: false });
63
+
64
+ const result1 = await lock.tryAcquire('consistent-task');
65
+ await lock.release('consistent-task');
66
+
67
+ const result2 = await lock2.tryAcquire('consistent-task');
68
+ await lock2.release('consistent-task');
69
+
70
+ expect(result1.lockKey).toBe(result2.lockKey);
71
+ });
72
+
73
+ test('generates different lock keys for different tasks', async () => {
74
+ const result1 = await lock.tryAcquire('task-a');
75
+ const result2 = await lock.tryAcquire('task-b');
76
+
77
+ expect(result1.lockKey).not.toBe(result2.lockKey);
78
+
79
+ await lock.release('task-a');
80
+ await lock.release('task-b');
81
+ });
82
+
83
+ test('fails to acquire already held lock', async () => {
84
+ // First instance acquires lock
85
+ const result1 = await lock.tryAcquire('exclusive-task');
86
+ expect(result1.acquired).toBe(true);
87
+
88
+ // Second instance tries to acquire same lock (simulated with same connection)
89
+ // Note: In real scenario, this would be a different database session
90
+ // For unit test, we verify the lock is tracked as held
91
+ expect(lock.isHeld('exclusive-task')).toBe(true);
92
+ });
93
+
94
+ test('returns true immediately when locking disabled', async () => {
95
+ const disabledLock = new DistributedLock({ enabled: false });
96
+
97
+ const result = await lock.tryAcquire('disabled-test');
98
+
99
+ // When disabled, lock is always "acquired" with key 0
100
+ const disabledResult = await disabledLock.tryAcquire('disabled-test');
101
+ expect(disabledResult.acquired).toBe(true);
102
+ expect(disabledResult.lockKey).toBe(0n);
103
+ });
104
+
105
+ test('tracks held locks locally', async () => {
106
+ expect(lock.getHeldLockCount()).toBe(0);
107
+
108
+ await lock.tryAcquire('tracked-task-1');
109
+ expect(lock.getHeldLockCount()).toBe(1);
110
+ expect(lock.isHeld('tracked-task-1')).toBe(true);
111
+
112
+ await lock.tryAcquire('tracked-task-2');
113
+ expect(lock.getHeldLockCount()).toBe(2);
114
+ expect(lock.isHeld('tracked-task-2')).toBe(true);
115
+ });
116
+ });
117
+
118
+ describe('release()', () => {
119
+ test('releases held lock', async () => {
120
+ await lock.tryAcquire('release-test');
121
+ expect(lock.isHeld('release-test')).toBe(true);
122
+
123
+ const released = await lock.release('release-test');
124
+
125
+ expect(released).toBe(true);
126
+ expect(lock.isHeld('release-test')).toBe(false);
127
+ expect(lock.getHeldLockCount()).toBe(0);
128
+ });
129
+
130
+ test('returns false for non-held lock', async () => {
131
+ const released = await lock.release('never-acquired');
132
+
133
+ // PostgreSQL returns false if lock wasn't held
134
+ expect(released).toBe(false);
135
+ });
136
+
137
+ test('does nothing when disabled', async () => {
138
+ const disabledLock = new DistributedLock({ enabled: false });
139
+
140
+ const released = await disabledLock.release('any-task');
141
+
142
+ expect(released).toBe(true);
143
+ });
144
+ });
145
+
146
+ describe('releaseAll()', () => {
147
+ test('releases all held locks', async () => {
148
+ await lock.tryAcquire('multi-1');
149
+ await lock.tryAcquire('multi-2');
150
+ await lock.tryAcquire('multi-3');
151
+
152
+ expect(lock.getHeldLockCount()).toBe(3);
153
+
154
+ await lock.releaseAll();
155
+
156
+ expect(lock.getHeldLockCount()).toBe(0);
157
+ expect(lock.isHeld('multi-1')).toBe(false);
158
+ expect(lock.isHeld('multi-2')).toBe(false);
159
+ expect(lock.isHeld('multi-3')).toBe(false);
160
+ });
161
+
162
+ test('handles empty lock set gracefully', async () => {
163
+ expect(lock.getHeldLockCount()).toBe(0);
164
+
165
+ // Should not throw
166
+ await lock.releaseAll();
167
+
168
+ expect(lock.getHeldLockCount()).toBe(0);
169
+ });
170
+ });
171
+
172
+ describe('updateConfig()', () => {
173
+ test('updates configuration', () => {
174
+ const originalConfig = lock.getConfig();
175
+
176
+ lock.updateConfig({
177
+ enabled: false,
178
+ lockTimeout: 10000,
179
+ });
180
+
181
+ const newConfig = lock.getConfig();
182
+ expect(newConfig.enabled).toBe(false);
183
+ expect(newConfig.lockTimeout).toBe(10000);
184
+ // Other values should remain
185
+ expect(newConfig.lockKeyPrefix).toBe(originalConfig.lockKeyPrefix);
186
+ });
187
+ });
188
+
189
+ describe('isHeld()', () => {
190
+ test('returns false for never acquired task', () => {
191
+ expect(lock.isHeld('unknown-task')).toBe(false);
192
+ });
193
+
194
+ test('returns true for acquired task', async () => {
195
+ await lock.tryAcquire('held-task');
196
+ expect(lock.isHeld('held-task')).toBe(true);
197
+ });
198
+
199
+ test('returns false after release', async () => {
200
+ await lock.tryAcquire('was-held-task');
201
+ expect(lock.isHeld('was-held-task')).toBe(true);
202
+
203
+ await lock.release('was-held-task');
204
+ expect(lock.isHeld('was-held-task')).toBe(false);
205
+ });
206
+ });
207
+
208
+ describe('lock key generation', () => {
209
+ test('generates positive bigint keys', async () => {
210
+ const tasks = ['task-1', 'task-2', 'TaskWithCaps', 'task.with.dots', 'task-with-dashes'];
211
+
212
+ for (const task of tasks) {
213
+ const result = await lock.tryAcquire(task);
214
+ expect(result.lockKey).toBeGreaterThan(0n);
215
+ await lock.release(task);
216
+ }
217
+ });
218
+
219
+ test('handles empty string task id', async () => {
220
+ const result = await lock.tryAcquire('');
221
+ expect(result.lockKey).toBeDefined();
222
+ await lock.release('');
223
+ });
224
+
225
+ test('handles unicode task ids', async () => {
226
+ const result = await lock.tryAcquire('task-日本語');
227
+ expect(result.acquired).toBe(true);
228
+ expect(result.lockKey).toBeGreaterThan(0n);
229
+ await lock.release('task-日本語');
230
+ });
231
+
232
+ test('handles very long task ids', async () => {
233
+ const longId = 'a'.repeat(1000);
234
+ const result = await lock.tryAcquire(longId);
235
+ expect(result.acquired).toBe(true);
236
+ expect(result.lockKey).toBeGreaterThan(0n);
237
+ await lock.release(longId);
238
+ });
239
+ });
240
+ });
241
+
242
+ describe('DistributedLock with timeout', () => {
243
+ let lock: DistributedLock;
244
+
245
+ beforeEach(() => {
246
+ resetDistributedLock();
247
+ lock = new DistributedLock({
248
+ enabled: true,
249
+ enableLogging: false,
250
+ lockTimeout: 200, // 200ms timeout
251
+ retryInterval: 50,
252
+ });
253
+ });
254
+
255
+ afterEach(async () => {
256
+ await lock.releaseAll();
257
+ resetDistributedLock();
258
+ });
259
+
260
+ test('retries until timeout when lock not available', async () => {
261
+ // This test verifies the retry mechanism is called
262
+ // In a real multi-instance scenario, we'd test across database sessions
263
+
264
+ const startTime = Date.now();
265
+
266
+ // First acquire the lock
267
+ const result1 = await lock.tryAcquire('timeout-task');
268
+ expect(result1.acquired).toBe(true);
269
+
270
+ const elapsed = Date.now() - startTime;
271
+ // Should return quickly when lock is available
272
+ expect(elapsed).toBeLessThan(100);
273
+ });
274
+ });