bunsane 0.3.1 → 0.4.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 (224) hide show
  1. package/CHANGELOG.md +445 -318
  2. package/config/cache.config.ts +35 -1
  3. package/core/App.ts +24 -1064
  4. package/core/ArcheType.ts +78 -2110
  5. package/core/BatchLoader.ts +56 -32
  6. package/core/Entity.ts +85 -1043
  7. package/core/EntityHookManager.ts +52 -754
  8. package/core/Logger.ts +10 -0
  9. package/core/RequestContext.ts +64 -6
  10. package/core/RequestLoaders.ts +187 -36
  11. package/core/SchedulerManager.ts +28 -600
  12. package/core/app/bootstrap.ts +133 -0
  13. package/core/app/cors.ts +85 -0
  14. package/core/app/graphqlSetup.ts +56 -0
  15. package/core/app/healthEndpoints.ts +31 -0
  16. package/core/app/metricsCollector.ts +27 -0
  17. package/core/app/preparedStatementWarmup.ts +15 -0
  18. package/core/app/processHandlers.ts +43 -0
  19. package/core/app/requestRouter.ts +310 -0
  20. package/core/app/restRegistry.ts +80 -0
  21. package/core/app/shutdown.ts +97 -0
  22. package/core/app/studioRouter.ts +83 -0
  23. package/core/archetype/customTypes.ts +100 -0
  24. package/core/archetype/decorators.ts +171 -0
  25. package/core/archetype/fieldResolvers.ts +666 -0
  26. package/core/archetype/helpers.ts +29 -0
  27. package/core/archetype/relationLoader.ts +161 -0
  28. package/core/archetype/schemaBuilder.ts +141 -0
  29. package/core/archetype/weaver.ts +218 -0
  30. package/core/archetype/zodSchemaBuilder.ts +527 -0
  31. package/core/cache/CacheManager.ts +173 -267
  32. package/core/cache/CompressionUtils.ts +34 -3
  33. package/core/cache/MemoryCache.ts +40 -37
  34. package/core/cache/RedisCache.ts +4 -4
  35. package/core/cache/health.ts +30 -0
  36. package/core/cache/invalidation.ts +96 -0
  37. package/core/cache/strategies/writeInvalidate.ts +111 -0
  38. package/core/cache/strategies/writeThrough.ts +233 -0
  39. package/core/components/BaseComponent.ts +16 -8
  40. package/core/components/ComponentRegistry.ts +28 -0
  41. package/core/decorators/IndexedField.ts +1 -1
  42. package/core/entity/cacheStrategies.ts +97 -0
  43. package/core/entity/componentAccess.ts +364 -0
  44. package/core/entity/finders.ts +202 -0
  45. package/core/entity/pendingOps.ts +72 -0
  46. package/core/entity/saveEntity.ts +377 -0
  47. package/core/hooks/dispatcher.ts +439 -0
  48. package/core/hooks/guards.ts +155 -0
  49. package/core/hooks/registry.ts +247 -0
  50. package/core/metadata/definitions/Component.ts +1 -1
  51. package/core/metadata/index.ts +15 -4
  52. package/core/middleware/AccessLog.ts +8 -1
  53. package/core/middleware/RateLimit.ts +102 -105
  54. package/core/middleware/RequestId.ts +2 -9
  55. package/core/middleware/SecurityHeaders.ts +2 -11
  56. package/core/middleware/headers.ts +28 -0
  57. package/core/remote/OutboxWorker.ts +213 -183
  58. package/core/remote/RemoteManager.ts +401 -400
  59. package/core/remote/types.ts +153 -151
  60. package/core/requestScope.ts +34 -0
  61. package/core/scheduler/cronEvaluator.ts +174 -0
  62. package/core/scheduler/lifecycleHooks.ts +21 -0
  63. package/core/scheduler/lockCoordinator.ts +27 -0
  64. package/core/scheduler/metrics.ts +14 -0
  65. package/core/scheduler/taskRunner.ts +420 -0
  66. package/database/DatabaseHelper.ts +128 -101
  67. package/database/IndexingStrategy.ts +72 -2
  68. package/database/PreparedStatementCache.ts +20 -5
  69. package/database/cancellable.ts +35 -0
  70. package/database/index.ts +15 -3
  71. package/database/instrumentedDb.ts +141 -0
  72. package/endpoints/archetypes.ts +2 -8
  73. package/endpoints/tables.ts +6 -1
  74. package/gql/index.ts +1 -1
  75. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  76. package/package.json +22 -1
  77. package/query/CTENode.ts +5 -3
  78. package/query/ComponentInclusionNode.ts +240 -13
  79. package/query/OrNode.ts +6 -5
  80. package/query/Query.ts +203 -59
  81. package/query/QueryContext.ts +6 -0
  82. package/query/QueryDAG.ts +7 -2
  83. package/query/membershipSource.ts +66 -0
  84. package/storage/LocalStorageProvider.ts +8 -3
  85. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  86. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  87. package/studio/{index.html → dist/index.html} +3 -2
  88. package/swagger/generator.ts +11 -1
  89. package/upload/UploadManager.ts +8 -6
  90. package/utils/uuid.ts +40 -10
  91. package/.claude/settings.local.json +0 -47
  92. package/.prettierrc +0 -4
  93. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  94. package/.serena/memories/architecture.md +0 -154
  95. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  96. package/.serena/memories/code_style_and_conventions.md +0 -76
  97. package/.serena/memories/project_overview.md +0 -43
  98. package/.serena/memories/schema-dsl-plan.md +0 -107
  99. package/.serena/memories/suggested_commands.md +0 -80
  100. package/.serena/memories/typescript-compilation-status.md +0 -54
  101. package/.serena/project.yml +0 -114
  102. package/BunSane.jpg +0 -0
  103. package/CLAUDE.md +0 -198
  104. package/TODO.md +0 -2
  105. package/bun.lock +0 -302
  106. package/bunfig.toml +0 -10
  107. package/docs/SCALABILITY_PLAN.md +0 -175
  108. package/studio/bun.lock +0 -482
  109. package/studio/package.json +0 -39
  110. package/studio/postcss.config.js +0 -6
  111. package/studio/src/components/DataTable.tsx +0 -211
  112. package/studio/src/components/Layout.tsx +0 -13
  113. package/studio/src/components/PageContainer.tsx +0 -9
  114. package/studio/src/components/PageHeader.tsx +0 -13
  115. package/studio/src/components/SearchBar.tsx +0 -57
  116. package/studio/src/components/Sidebar.tsx +0 -294
  117. package/studio/src/components/ui/button.tsx +0 -56
  118. package/studio/src/components/ui/checkbox.tsx +0 -26
  119. package/studio/src/components/ui/input.tsx +0 -25
  120. package/studio/src/hooks/useDataTable.ts +0 -131
  121. package/studio/src/index.css +0 -36
  122. package/studio/src/lib/api.ts +0 -186
  123. package/studio/src/lib/utils.ts +0 -13
  124. package/studio/src/main.tsx +0 -17
  125. package/studio/src/pages/ArcheType.tsx +0 -239
  126. package/studio/src/pages/Components.tsx +0 -124
  127. package/studio/src/pages/EntityInspector.tsx +0 -302
  128. package/studio/src/pages/QueryRunner.tsx +0 -246
  129. package/studio/src/pages/Table.tsx +0 -94
  130. package/studio/src/pages/Welcome.tsx +0 -241
  131. package/studio/src/routes.tsx +0 -45
  132. package/studio/src/store/archeTypeSettings.ts +0 -30
  133. package/studio/src/store/studio.ts +0 -65
  134. package/studio/src/utils/columnHelpers.tsx +0 -114
  135. package/studio/studio-instructions.md +0 -81
  136. package/studio/tailwind.config.js +0 -77
  137. package/studio/utils.ts +0 -54
  138. package/studio/vite.config.js +0 -19
  139. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  140. package/tests/benchmark/bunfig.toml +0 -9
  141. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  142. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  143. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  144. package/tests/benchmark/fixtures/index.ts +0 -6
  145. package/tests/benchmark/index.ts +0 -22
  146. package/tests/benchmark/noop-preload.ts +0 -3
  147. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  148. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  149. package/tests/benchmark/runners/index.ts +0 -4
  150. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  151. package/tests/benchmark/scripts/generate-db.ts +0 -344
  152. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  153. package/tests/e2e/http.test.ts +0 -130
  154. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  155. package/tests/fixtures/components/TestOrder.ts +0 -23
  156. package/tests/fixtures/components/TestProduct.ts +0 -23
  157. package/tests/fixtures/components/TestUser.ts +0 -20
  158. package/tests/fixtures/components/index.ts +0 -6
  159. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  160. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  161. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  162. package/tests/helpers/MockRedisClient.ts +0 -113
  163. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  164. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  165. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  166. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  167. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  168. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  169. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  170. package/tests/integration/query/Query.exec.test.ts +0 -576
  171. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  172. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  173. package/tests/integration/remote/dlq.test.ts +0 -175
  174. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  175. package/tests/integration/remote/outbox.test.ts +0 -130
  176. package/tests/integration/remote/rpc.test.ts +0 -177
  177. package/tests/pglite-setup.ts +0 -62
  178. package/tests/setup.ts +0 -164
  179. package/tests/stress/BenchmarkRunner.ts +0 -203
  180. package/tests/stress/DataSeeder.ts +0 -190
  181. package/tests/stress/StressTestReporter.ts +0 -229
  182. package/tests/stress/cursor-perf-test.ts +0 -171
  183. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  184. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  185. package/tests/stress/index.ts +0 -7
  186. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  187. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  188. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  189. package/tests/unit/BatchLoader.test.ts +0 -196
  190. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  191. package/tests/unit/cache/CacheManager.test.ts +0 -367
  192. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  193. package/tests/unit/cache/RedisCache.test.ts +0 -411
  194. package/tests/unit/entity/Entity.components.test.ts +0 -317
  195. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  196. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  197. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  198. package/tests/unit/entity/Entity.test.ts +0 -345
  199. package/tests/unit/gql/depthLimit.test.ts +0 -203
  200. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  201. package/tests/unit/health/Health.test.ts +0 -129
  202. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  203. package/tests/unit/middleware/Middleware.test.ts +0 -98
  204. package/tests/unit/middleware/RequestId.test.ts +0 -54
  205. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  206. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  207. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  208. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  209. package/tests/unit/query/Query.test.ts +0 -310
  210. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  211. package/tests/unit/remote/RemoteError.test.ts +0 -55
  212. package/tests/unit/remote/decorators.test.ts +0 -195
  213. package/tests/unit/remote/metrics.test.ts +0 -115
  214. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  215. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  216. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  217. package/tests/unit/schema/schema-integration.test.ts +0 -426
  218. package/tests/unit/schema/schema.test.ts +0 -580
  219. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  220. package/tests/unit/upload/RestUpload.test.ts +0 -267
  221. package/tests/unit/validateEnv.test.ts +0 -82
  222. package/tests/utils/entity-tracker.ts +0 -57
  223. package/tests/utils/index.ts +0 -13
  224. package/tests/utils/test-context.ts +0 -149
@@ -19,20 +19,25 @@ import type { ComponentTargetConfig } from "./EntityHookManager";
19
19
  import ArcheType from "./ArcheType";
20
20
  import { BaseComponent } from "./components";
21
21
  import { DistributedLock, type DistributedLockConfig } from "./scheduler/DistributedLock";
22
+ import { scheduleTask, scheduleJob } from "./scheduler/cronEvaluator";
23
+ import { executeTask, doExecuteTask, updateTaskMetrics } from "./scheduler/taskRunner";
24
+ import { getDistributedLockInfo, isDistributedLockingEnabled, syncLockConfig } from "./scheduler/lockCoordinator";
25
+ import { initializeLifecycleIntegration, disposeLifecycleIntegration as _disposeLifecycleIntegration } from "./scheduler/lifecycleHooks";
26
+ import { getMetrics, getTaskMetrics, getAllTaskMetrics } from "./scheduler/metrics";
22
27
 
23
28
  const loggerInstance = logger.child({ scope: "SchedulerManager" });
24
29
 
25
30
  export class SchedulerManager {
26
31
  private static instance: SchedulerManager;
27
- private tasks: Map<string, ScheduledTaskInfo> = new Map();
28
- private intervals: Map<string, NodeJS.Timeout> = new Map();
29
- private isRunning: boolean = false;
32
+ public tasks: Map<string, ScheduledTaskInfo> = new Map();
33
+ public intervals: Map<string, NodeJS.Timeout> = new Map();
34
+ public isRunning: boolean = false;
30
35
  private eventListeners: SchedulerEventCallback[] = [];
31
36
  public config: SchedulerConfig;
32
- private distributedLock: DistributedLock;
33
- private phaseListener: ((event: PhaseChangeEvent) => void) | null = null;
34
- private inflightTasks: Set<Promise<any>> = new Set();
35
- private metrics: SchedulerMetrics = {
37
+ public distributedLock: DistributedLock;
38
+ public phaseListener: ((event: PhaseChangeEvent) => void) | null = null;
39
+ public inflightTasks: Set<Promise<any>> = new Set();
40
+ public metrics: SchedulerMetrics = {
36
41
  totalTasks: 0,
37
42
  runningTasks: 0,
38
43
  completedExecutions: 0,
@@ -67,7 +72,7 @@ export class SchedulerManager {
67
72
  retryInterval: this.config.lockRetryInterval ?? 100,
68
73
  });
69
74
 
70
- this.initializeLifecycleIntegration();
75
+ initializeLifecycleIntegration(this);
71
76
  }
72
77
 
73
78
  public static getInstance(): SchedulerManager {
@@ -77,23 +82,8 @@ export class SchedulerManager {
77
82
  return SchedulerManager.instance;
78
83
  }
79
84
 
80
- private initializeLifecycleIntegration(): void {
81
- this.phaseListener = (event) => {
82
- const phase = event.detail;
83
- if (phase === ApplicationPhase.APPLICATION_READY) {
84
- if (this.config.runOnStart) {
85
- this.start();
86
- }
87
- }
88
- };
89
- ApplicationLifecycle.addPhaseListener(this.phaseListener);
90
- }
91
-
92
85
  public disposeLifecycleIntegration(): void {
93
- if (this.phaseListener) {
94
- ApplicationLifecycle.removePhaseListener(this.phaseListener);
95
- this.phaseListener = null;
96
- }
86
+ _disposeLifecycleIntegration(this);
97
87
  }
98
88
 
99
89
  public registerTask(taskInfo: ScheduledTaskInfo): void {
@@ -128,7 +118,7 @@ export class SchedulerManager {
128
118
 
129
119
  // Try to schedule the task - if scheduling fails, don't register it
130
120
  try {
131
- this.scheduleTask(taskInfo);
121
+ scheduleTask(this, taskInfo);
132
122
  this.tasks.set(taskInfo.id, taskInfo);
133
123
  this.metrics.totalTasks++;
134
124
 
@@ -154,341 +144,15 @@ export class SchedulerManager {
154
144
  * entity-component system integration.
155
145
  */
156
146
  public scheduleJob(name: string, cronExpression: string, callback: () => Promise<void> | void): { cancel: () => void } {
157
- const jobId = `job_${name}_${Date.now()}`;
158
-
159
- // Validate cron expression
160
- const validation = CronParser.validate(cronExpression);
161
- if (!validation.isValid) {
162
- throw new Error(`Invalid cron expression for job "${name}": ${validation.error}`);
163
- }
164
-
165
- let timeoutId: ReturnType<typeof setTimeout> | null = null;
166
- let cancelled = false;
167
-
168
- const scheduleNextExecution = () => {
169
- if (cancelled) return;
170
-
171
- const nextExecution = CronParser.getNextExecution(validation.fields!, new Date());
172
- if (!nextExecution) {
173
- loggerInstance.warn(`Unable to calculate next execution for job "${name}"`);
174
- return;
175
- }
176
-
177
- const delay = nextExecution.getTime() - Date.now();
178
- timeoutId = setTimeout(async () => {
179
- if (cancelled) return;
180
- try {
181
- await callback();
182
- } catch (error) {
183
- loggerInstance.error(`Job "${name}" failed: ${error instanceof Error ? error.message : String(error)}`);
184
- }
185
- scheduleNextExecution();
186
- }, delay);
187
-
188
- this.intervals.set(jobId, timeoutId as any);
189
- };
190
-
191
- scheduleNextExecution();
192
-
193
- return {
194
- cancel: () => {
195
- cancelled = true;
196
- if (timeoutId) {
197
- clearTimeout(timeoutId);
198
- this.intervals.delete(jobId);
199
- }
200
- }
201
- };
202
- }
203
-
204
- private scheduleTask(taskInfo: ScheduledTaskInfo): void {
205
- try {
206
- if (taskInfo.interval === ScheduleInterval.CRON) {
207
- this.scheduleCronTask(taskInfo);
208
- } else {
209
- this.scheduleIntervalTask(taskInfo);
210
- }
211
- } catch (error) {
212
- loggerInstance.error(`Failed to schedule task ${taskInfo.name}: ${error instanceof Error ? error.message : String(error)}`);
213
- throw error;
214
- }
215
- }
216
-
217
- private scheduleIntervalTask(taskInfo: ScheduledTaskInfo): void {
218
- const intervalMs = this.getIntervalMilliseconds(taskInfo.interval);
219
-
220
- // Clear any existing interval for this task before creating a new one
221
- const existingInterval = this.intervals.get(taskInfo.id);
222
- if (existingInterval) {
223
- clearInterval(existingInterval);
224
- this.intervals.delete(taskInfo.id);
225
- }
226
-
227
- // For very long intervals (monthly), use a different approach
228
- if (intervalMs > 24 * 60 * 60 * 1000) { // More than 24 hours
229
- this.scheduleLongIntervalTask(taskInfo, intervalMs);
230
- } else {
231
- const intervalId = setInterval(async () => {
232
- await this.executeTask(taskInfo.id);
233
- }, intervalMs);
234
-
235
- this.intervals.set(taskInfo.id, intervalId);
236
- taskInfo.nextExecution = new Date(Date.now() + intervalMs);
237
- }
238
-
239
- if (this.config.enableLogging) {
240
- loggerInstance.info(`Scheduled task ${taskInfo.name} to run every ${intervalMs}ms`);
241
- }
242
- }
243
-
244
- private scheduleLongIntervalTask(taskInfo: ScheduledTaskInfo, intervalMs: number): void {
245
- // For very long intervals, use a shorter check interval to avoid timeout overflow
246
- const checkInterval = Math.min(intervalMs, 24 * 60 * 60 * 1000); // Max 24 hours check interval
247
- const nextExecution = new Date(Date.now() + intervalMs);
248
- taskInfo.nextExecution = nextExecution;
249
-
250
- const intervalId = setInterval(async () => {
251
- const now = Date.now();
252
- if (now >= nextExecution.getTime()) {
253
- await this.executeTask(taskInfo.id);
254
- // Reschedule for next execution
255
- taskInfo.nextExecution = new Date(now + intervalMs);
256
- }
257
- }, checkInterval);
258
-
259
- this.intervals.set(taskInfo.id, intervalId);
260
- }
261
-
262
- private scheduleCronTask(taskInfo: ScheduledTaskInfo): void {
263
- if (!taskInfo.cronExpression) {
264
- throw new Error(`Cron expression is required for CRON interval tasks`);
265
- }
266
-
267
- // Validate cron expression
268
- const validation = CronParser.validate(taskInfo.cronExpression);
269
- if (!validation.isValid) {
270
- throw new Error(`Invalid cron expression: ${validation.error}`);
271
- }
272
-
273
- // Calculate next execution time
274
- const nextExecution = CronParser.getNextExecution(validation.fields!, new Date());
275
- if (!nextExecution) {
276
- throw new Error(`Unable to calculate next execution time for cron expression: ${taskInfo.cronExpression}`);
277
- }
278
-
279
- taskInfo.nextExecution = nextExecution;
280
-
281
- // Clear any existing timeout for this task before creating a new one
282
- const existingTimeout = this.intervals.get(taskInfo.id);
283
- if (existingTimeout) {
284
- clearTimeout(existingTimeout as any);
285
- }
286
-
287
- // Schedule the task to run at the calculated time
288
- const timeoutId = setTimeout(async () => {
289
- await this.executeTask(taskInfo.id);
290
- // Reschedule for next execution
291
- this.scheduleCronTask(taskInfo);
292
- }, nextExecution.getTime() - Date.now());
293
-
294
- this.intervals.set(taskInfo.id, timeoutId as any);
295
-
296
- if (this.config.enableLogging) {
297
- loggerInstance.info(`Scheduled cron task ${taskInfo.name} to run at ${nextExecution.toISOString()}`);
298
- }
299
- }
300
-
301
- private getIntervalMilliseconds(interval: ScheduleInterval): number {
302
- switch (interval) {
303
- case ScheduleInterval.MINUTE:
304
- return 60 * 1000; // 1 minute
305
- case ScheduleInterval.HOUR:
306
- return 60 * 60 * 1000; // 1 hour
307
- case ScheduleInterval.DAILY:
308
- return 24 * 60 * 60 * 1000; // 24 hours
309
- case ScheduleInterval.WEEKLY:
310
- return 7 * 24 * 60 * 60 * 1000; // 7 days
311
- case ScheduleInterval.MONTHLY:
312
- return 30 * 24 * 60 * 60 * 1000; // 30 days (approximate)
313
- default:
314
- throw new Error(`Unsupported interval: ${interval}`);
315
- }
147
+ return scheduleJob(this, name, cronExpression, callback);
316
148
  }
317
149
 
318
150
  private async executeTask(taskId: string): Promise<void> {
319
- // Track this execution so stop() can await in-flight work before
320
- // resources (DB pool, cache) are torn down. Without this, a task mid-
321
- // write during SIGTERM hits a closed DB pool and silently corrupts
322
- // or loses data.
323
- const p = this.doExecuteTask(taskId);
324
- this.inflightTasks.add(p);
325
- p.finally(() => this.inflightTasks.delete(p));
326
- return p;
151
+ return executeTask(this, taskId);
327
152
  }
328
153
 
329
154
  private async doExecuteTask(taskId: string): Promise<void> {
330
- const taskInfo = this.tasks.get(taskId);
331
- if (!taskInfo || !taskInfo.enabled) {
332
- return;
333
- }
334
-
335
- // Skip if the previous tick is still executing. Without this guard
336
- // a slow task with interval < execution-time burns a lock-acquire
337
- // round-trip every tick and floods the skipped-executions metric
338
- // (H-SCHED-1). Cheap in-process check before reaching out to PG.
339
- if (taskInfo.isRunning) {
340
- this.metrics.skippedExecutions++;
341
- if (this.config.enableLogging) {
342
- loggerInstance.debug(`Task ${taskInfo.name} skipped - previous execution still running`);
343
- }
344
- return;
345
- }
346
-
347
- if (this.metrics.runningTasks >= this.config.maxConcurrentTasks) {
348
- if (this.config.enableLogging) {
349
- loggerInstance.warn(`Maximum concurrent tasks reached. Skipping execution of ${taskInfo.name}`);
350
- }
351
- return;
352
- }
353
-
354
- // Try to acquire distributed lock before executing
355
- this.metrics.lockAttempts++;
356
- const lockResult = await this.distributedLock.tryAcquire(taskId);
357
-
358
- if (!lockResult.acquired) {
359
- // Another instance is executing this task
360
- this.metrics.skippedExecutions++;
361
-
362
- if (this.config.enableLogging) {
363
- loggerInstance.debug(`Task ${taskInfo.name} skipped - another instance is executing (lock key: ${lockResult.lockKey})`);
364
- }
365
-
366
- this.emitEvent({
367
- type: 'task.skipped',
368
- taskId: taskInfo.id,
369
- timestamp: new Date(),
370
- data: { reason: 'lock_unavailable', lockKey: lockResult.lockKey.toString() }
371
- });
372
-
373
- return;
374
- }
375
-
376
- // Lock acquired successfully
377
- this.metrics.locksAcquired++;
378
-
379
- this.emitEvent({
380
- type: 'task.lock.acquired',
381
- taskId: taskInfo.id,
382
- timestamp: new Date(),
383
- data: { lockKey: lockResult.lockKey.toString() }
384
- });
385
-
386
- taskInfo.isRunning = true;
387
- taskInfo.lastExecution = new Date();
388
- this.metrics.runningTasks++;
389
-
390
- const startTime = Date.now();
391
- const timeout = taskInfo.options?.timeout || this.config.defaultTimeout;
392
-
393
- try {
394
- // Create query based on targeting configuration
395
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
396
- let query: Query<any> | null = null;
397
-
398
- if (taskInfo.options?.query) {
399
- // Use custom query function (preferred approach)
400
- query = taskInfo.options.query();
401
- } else if (taskInfo.options?.componentTarget) {
402
- // Use component targeting configuration (deprecated - use query instead)
403
- const componentTarget = taskInfo.options.componentTarget;
404
- query = this.buildQueryFromComponentTarget(componentTarget);
405
- } else if (taskInfo.componentTarget) {
406
- // Use legacy single component targeting (deprecated - use query instead)
407
- query = new Query().with(taskInfo.componentTarget);
408
- }
409
- // else: time-based task — no entity selection. Handler invoked
410
- // with no arguments on each tick.
411
-
412
- // Apply entity limit if specified (can be used with query function)
413
- if (query && taskInfo.options?.maxEntitiesPerExecution) {
414
- query.take(taskInfo.options.maxEntitiesPerExecution);
415
- }
416
-
417
- const entities = query ? await query.exec() : [];
418
-
419
- // Execute the scheduled method with the entities array
420
- const method = taskInfo.service[taskInfo.methodName];
421
- if (typeof method !== 'function') {
422
- throw new Error(`Method ${taskInfo.methodName} not found on service`);
423
- }
424
-
425
- // Execute with timeout. Time-based tasks receive no entity arg.
426
- const result = await this.executeWithTimeout(
427
- query
428
- ? method.call(taskInfo.service, entities)
429
- : method.call(taskInfo.service),
430
- timeout,
431
- taskInfo
432
- );
433
-
434
- const duration = Date.now() - startTime;
435
- taskInfo.executionCount++;
436
- this.metrics.completedExecutions++;
437
- this.metrics.totalExecutionTime += duration;
438
- this.metrics.averageExecutionTime = this.metrics.totalExecutionTime / this.metrics.completedExecutions;
439
-
440
- // Update task-specific metrics
441
- this.updateTaskMetrics(taskInfo.id, {
442
- totalExecutions: taskInfo.executionCount,
443
- successfulExecutions: (this.metrics.taskMetrics[taskInfo.id]?.successfulExecutions || 0) + 1,
444
- averageExecutionTime: duration,
445
- lastExecutionTime: new Date(),
446
- totalEntitiesProcessed: entities.length
447
- });
448
-
449
- if (this.config.enableLogging) {
450
- loggerInstance.info(`Task ${taskInfo.name} completed successfully in ${duration}ms (processed ${entities.length} entities)`);
451
- }
452
-
453
- this.emitEvent({
454
- type: 'task.executed',
455
- taskId: taskInfo.id,
456
- timestamp: new Date(),
457
- data: { duration, entitiesProcessed: entities.length, success: true }
458
- });
459
-
460
- } catch (error) {
461
- const duration = Date.now() - startTime;
462
- this.metrics.failedExecutions++;
463
-
464
- // Handle retry logic
465
- await this.handleTaskFailure(taskInfo, error instanceof Error ? error : new Error(String(error)), duration);
466
-
467
- if (this.config.enableLogging) {
468
- loggerInstance.error(`Task ${taskInfo.name} failed after ${duration}ms: ${error instanceof Error ? error.message : String(error)}`);
469
- }
470
-
471
- this.emitEvent({
472
- type: 'task.failed',
473
- taskId: taskInfo.id,
474
- timestamp: new Date(),
475
- data: { duration, error: error instanceof Error ? error.message : String(error) }
476
- });
477
-
478
- } finally {
479
- taskInfo.isRunning = false;
480
- this.metrics.runningTasks--;
481
-
482
- // Release the distributed lock
483
- await this.distributedLock.release(taskId);
484
-
485
- this.emitEvent({
486
- type: 'task.lock.released',
487
- taskId: taskInfo.id,
488
- timestamp: new Date(),
489
- data: { lockKey: lockResult.lockKey.toString() }
490
- });
491
- }
155
+ return doExecuteTask(this, taskId);
492
156
  }
493
157
 
494
158
  public start(): void {
@@ -510,7 +174,7 @@ export class SchedulerManager {
510
174
 
511
175
  // Schedule all registered tasks in priority order
512
176
  for (const taskInfo of sortedTasks) {
513
- this.scheduleTask(taskInfo);
177
+ scheduleTask(this, taskInfo);
514
178
  }
515
179
 
516
180
  const lockStatus = this.config.distributedLocking !== false ? 'enabled' : 'disabled';
@@ -577,7 +241,7 @@ export class SchedulerManager {
577
241
  }
578
242
 
579
243
  public getMetrics(): SchedulerMetrics {
580
- return { ...this.metrics };
244
+ return getMetrics(this);
581
245
  }
582
246
 
583
247
  public getTasks(): ScheduledTaskInfo[] {
@@ -592,7 +256,7 @@ export class SchedulerManager {
592
256
 
593
257
  task.enabled = true;
594
258
  if (this.isRunning) {
595
- this.scheduleTask(task);
259
+ scheduleTask(this, task);
596
260
  }
597
261
  return true;
598
262
  }
@@ -624,7 +288,7 @@ export class SchedulerManager {
624
288
  }
625
289
  }
626
290
 
627
- private emitEvent(event: SchedulerEvent): void {
291
+ public emitEvent(event: SchedulerEvent): void {
628
292
  for (const listener of this.eventListeners) {
629
293
  try {
630
294
  listener(event);
@@ -638,12 +302,7 @@ export class SchedulerManager {
638
302
  this.config = { ...this.config, ...config };
639
303
 
640
304
  // Sync distributed lock configuration
641
- this.distributedLock.updateConfig({
642
- enabled: this.config.distributedLocking ?? true,
643
- enableLogging: this.config.enableLogging,
644
- lockTimeout: this.config.lockTimeout ?? 0,
645
- retryInterval: this.config.lockRetryInterval ?? 100,
646
- });
305
+ syncLockConfig(this);
647
306
 
648
307
  if (this.config.enableLogging) {
649
308
  loggerInstance.info(`Scheduler configuration updated: ${JSON.stringify(config)}`);
@@ -662,187 +321,28 @@ export class SchedulerManager {
662
321
  heldLocks: number;
663
322
  config: DistributedLockConfig;
664
323
  } {
665
- return {
666
- enabled: this.config.distributedLocking !== false,
667
- heldLocks: this.distributedLock.getHeldLockCount(),
668
- config: this.distributedLock.getConfig(),
669
- };
324
+ return getDistributedLockInfo(this);
670
325
  }
671
326
 
672
327
  /**
673
328
  * Check if distributed locking is enabled
674
329
  */
675
330
  public isDistributedLockingEnabled(): boolean {
676
- return this.config.distributedLocking !== false;
677
- }
678
-
679
- /**
680
- * Execute a task with timeout enforcement.
681
- *
682
- * Note: JS has no way to cancel an arbitrary Promise — on timeout the
683
- * wrapper rejects but the underlying task continues. The second .catch
684
- * below captures a late rejection after the wrapper already rejected,
685
- * preventing an unhandled-rejection process crash (H-SCHED-5). The
686
- * `settled` flag guards against double-settle.
687
- */
688
- private async executeWithTimeout<T>(task: Promise<T>, timeoutMs: number, taskInfo: ScheduledTaskInfo): Promise<T> {
689
- return new Promise((resolve, reject) => {
690
- let settled = false;
691
- const timeoutId = setTimeout(() => {
692
- if (settled) return;
693
- settled = true;
694
- this.metrics.timedOutTasks++;
695
- this.updateTaskMetrics(taskInfo.id, {
696
- timeoutCount: (this.metrics.taskMetrics[taskInfo.id]?.timeoutCount || 0) + 1
697
- });
698
- const error = new Error(`Task ${taskInfo.name} timed out after ${timeoutMs}ms`);
699
- this.emitEvent({
700
- type: 'task.timeout',
701
- taskId: taskInfo.id,
702
- timestamp: new Date(),
703
- data: { timeoutMs, taskName: taskInfo.name }
704
- });
705
- reject(error);
706
- }, timeoutMs);
707
-
708
- task
709
- .then((result) => {
710
- if (settled) return;
711
- settled = true;
712
- clearTimeout(timeoutId);
713
- resolve(result);
714
- })
715
- .catch((error) => {
716
- if (settled) {
717
- // Late rejection after timeout. Log only — the wrapper
718
- // promise is already settled, so re-rejecting would be
719
- // a no-op but the rejection would escape as unhandled.
720
- loggerInstance.warn({ taskId: taskInfo.id, err: error }, 'Late rejection from scheduled task after timeout');
721
- return;
722
- }
723
- settled = true;
724
- clearTimeout(timeoutId);
725
- reject(error);
726
- });
727
- });
728
- }
729
-
730
- /**
731
- * Handle task failure with retry logic
732
- */
733
- private async handleTaskFailure(taskInfo: ScheduledTaskInfo, error: Error, duration: number): Promise<void> {
734
- taskInfo.lastError = error.message;
735
-
736
- const maxRetries = taskInfo.options?.maxRetries || taskInfo.maxRetries || 0;
737
- const retryDelay = taskInfo.options?.retryDelay || 1000; // Default 1 second
738
-
739
- if (taskInfo.retryCount === undefined) {
740
- taskInfo.retryCount = 0;
741
- }
742
-
743
- if (taskInfo.retryCount < maxRetries) {
744
- taskInfo.retryCount++;
745
- this.metrics.retriedTasks++;
746
-
747
- this.updateTaskMetrics(taskInfo.id, {
748
- retryCount: taskInfo.retryCount
749
- });
750
-
751
- if (this.config.enableLogging) {
752
- loggerInstance.warn(`Task ${taskInfo.name} failed (attempt ${taskInfo.retryCount}/${maxRetries}), retrying in ${retryDelay}ms: ${error.message}`);
753
- }
754
-
755
- // Schedule retry. Track the timer handle in `intervals` under a
756
- // unique key so `stop()` can clear it (H-SCHED-3); without this
757
- // the retry fires post-shutdown against a closed DB pool. Also
758
- // skip the retry if the scheduler is no longer running by the
759
- // time the timer fires, and rely on the new isRunning guard in
760
- // doExecuteTask (H-SCHED-1) to prevent retry/tick overlap.
761
- const retryKey = `${taskInfo.id}:retry:${taskInfo.retryCount}`;
762
- const retryHandle = setTimeout(async () => {
763
- this.intervals.delete(retryKey);
764
- if (!this.isRunning) return;
765
- await this.executeTask(taskInfo.id);
766
- }, retryDelay);
767
- this.intervals.set(retryKey, retryHandle as any);
768
-
769
- this.emitEvent({
770
- type: 'task.retry',
771
- taskId: taskInfo.id,
772
- timestamp: new Date(),
773
- data: {
774
- attempt: taskInfo.retryCount,
775
- maxRetries,
776
- retryDelay,
777
- error: error.message
778
- }
779
- });
780
- } else {
781
- // Max retries reached or no retries configured
782
- this.updateTaskMetrics(taskInfo.id, {
783
- failedExecutions: (this.metrics.taskMetrics[taskInfo.id]?.failedExecutions || 0) + 1
784
- });
785
-
786
- if (this.config.enableLogging) {
787
- loggerInstance.error(`Task ${taskInfo.name} failed permanently after ${taskInfo.retryCount} attempts: ${error.message}`);
788
- }
789
-
790
- this.emitEvent({
791
- type: 'task.failed',
792
- taskId: taskInfo.id,
793
- timestamp: new Date(),
794
- data: {
795
- duration,
796
- error: error.message,
797
- attempts: taskInfo.retryCount,
798
- maxRetries
799
- }
800
- });
801
- }
802
- }
803
-
804
- /**
805
- * Update task-specific metrics
806
- */
807
- private updateTaskMetrics(taskId: string, updates: Partial<TaskMetrics>): void {
808
- if (!this.metrics.taskMetrics[taskId]) {
809
- const taskInfo = this.tasks.get(taskId);
810
- this.metrics.taskMetrics[taskId] = {
811
- taskId,
812
- taskName: taskInfo?.name || 'Unknown',
813
- totalExecutions: 0,
814
- successfulExecutions: 0,
815
- failedExecutions: 0,
816
- averageExecutionTime: 0,
817
- totalEntitiesProcessed: 0,
818
- retryCount: 0,
819
- timeoutCount: 0
820
- };
821
- }
822
-
823
- const metrics = this.metrics.taskMetrics[taskId];
824
- Object.assign(metrics, updates);
825
-
826
- // Update rolling averages
827
- if (updates.averageExecutionTime !== undefined) {
828
- const currentAvg = metrics.averageExecutionTime;
829
- const newCount = metrics.totalExecutions;
830
- metrics.averageExecutionTime = ((currentAvg * (newCount - 1)) + updates.averageExecutionTime) / newCount;
831
- }
331
+ return isDistributedLockingEnabled(this);
832
332
  }
833
333
 
834
334
  /**
835
335
  * Get detailed metrics for a specific task
836
336
  */
837
337
  public getTaskMetrics(taskId: string): TaskMetrics | null {
838
- return this.metrics.taskMetrics[taskId] || null;
338
+ return getTaskMetrics(this, taskId);
839
339
  }
840
340
 
841
341
  /**
842
342
  * Get all task metrics
843
343
  */
844
344
  public getAllTaskMetrics(): Record<string, TaskMetrics> {
845
- return { ...this.metrics.taskMetrics };
345
+ return getAllTaskMetrics(this);
846
346
  }
847
347
 
848
348
  /**
@@ -858,76 +358,4 @@ export class SchedulerManager {
858
358
  await this.executeTask(taskId);
859
359
  return true;
860
360
  }
861
-
862
- /**
863
- * Build a Query object from ComponentTargetConfig
864
- * @param componentTarget The component targeting configuration
865
- * @returns A Query object configured with the component targeting
866
- */
867
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
868
- private buildQueryFromComponentTarget(componentTarget: ComponentTargetConfig): Query<any> {
869
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
870
- let query: Query<any> = new Query();
871
-
872
- // Handle archetype matching first (most specific)
873
- if (componentTarget.archetype) {
874
- // For archetype matching, we need to include all components from the archetype
875
- const archetypeComponents = this.getArchetypeComponents(componentTarget.archetype);
876
- for (const component of archetypeComponents) {
877
- query = query.with(component);
878
- }
879
- } else if (componentTarget.archetypes && componentTarget.archetypes.length > 0) {
880
- // Handle multiple archetypes - for simplicity, we'll use the first valid one
881
- // In a more advanced implementation, you might want to handle OR logic
882
- const firstArchetype = componentTarget.archetypes.find(archetype => archetype !== undefined);
883
- if (firstArchetype) {
884
- const archetypeComponents = this.getArchetypeComponents(firstArchetype);
885
- for (const component of archetypeComponents) {
886
- query = query.with(component);
887
- }
888
- }
889
- }
890
-
891
- // Handle included components
892
- if (componentTarget.includeComponents && componentTarget.includeComponents.length > 0) {
893
- const requireAll = componentTarget.requireAllIncluded ?? true;
894
- if (requireAll) {
895
- // ALL included components must be present (AND logic)
896
- for (const component of componentTarget.includeComponents) {
897
- query = query.with(component);
898
- }
899
- } else {
900
- // ANY included component must be present (OR logic)
901
- // For OR logic with Query API, we need to use a different approach
902
- // This is a simplified implementation - in practice, you might need custom query logic
903
- for (const component of componentTarget.includeComponents) {
904
- query = query.with(component);
905
- break; // Just use the first one for simplicity
906
- }
907
- }
908
- }
909
-
910
- // Handle excluded components
911
- if (componentTarget.excludeComponents && componentTarget.excludeComponents.length > 0) {
912
- for(const component of componentTarget.excludeComponents){
913
- query = query.without(component);
914
- }
915
- }
916
-
917
- return query;
918
- }
919
-
920
- /**
921
- * Extract component classes from an ArcheType
922
- * @param archetype The archetype to extract components from
923
- * @returns Array of component classes
924
- */
925
- private getArchetypeComponents(archetype: ArcheType): (new () => BaseComponent)[] {
926
- // Access the private componentMap from ArcheType
927
- const componentMap = (archetype as any).componentMap as Record<string, new () => BaseComponent>;
928
- if (!componentMap) {
929
- return [];
930
- }
931
- return Object.values(componentMap);
932
- }
933
- }
361
+ }