@flink-app/flink 1.0.0 → 2.0.0-alpha.100

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 (277) hide show
  1. package/CHANGELOG.md +991 -0
  2. package/SCHEMA_EXTRACTION_ANALYSIS.md +494 -0
  3. package/SIMPLE_AST_FEASIBILITY.md +570 -0
  4. package/bin/flink.ts +13 -2
  5. package/cli/build.ts +24 -44
  6. package/cli/clean.ts +13 -25
  7. package/cli/cli-utils.ts +190 -17
  8. package/cli/dev.ts +252 -0
  9. package/cli/loadEnvFiles.ts +116 -0
  10. package/cli/run.ts +45 -62
  11. package/dist/bin/flink.js +61 -2
  12. package/dist/cli/build.js +20 -25
  13. package/dist/cli/clean.js +12 -10
  14. package/dist/cli/cli-utils.d.ts +34 -3
  15. package/dist/cli/cli-utils.js +193 -12
  16. package/dist/cli/dev.d.ts +2 -0
  17. package/dist/cli/dev.js +279 -0
  18. package/dist/cli/loadEnvFiles.d.ts +30 -0
  19. package/dist/cli/loadEnvFiles.js +113 -0
  20. package/dist/cli/run.js +47 -46
  21. package/dist/src/DependencyTracker.d.ts +44 -0
  22. package/dist/src/DependencyTracker.js +239 -0
  23. package/dist/src/FlinkApp.d.ts +163 -10
  24. package/dist/src/FlinkApp.js +823 -184
  25. package/dist/src/FlinkContext.d.ts +41 -0
  26. package/dist/src/FlinkErrors.d.ts +19 -6
  27. package/dist/src/FlinkErrors.js +36 -42
  28. package/dist/src/FlinkHttpHandler.d.ts +157 -18
  29. package/dist/src/FlinkJob.d.ts +10 -0
  30. package/dist/src/FlinkLog.d.ts +82 -18
  31. package/dist/src/FlinkLog.js +165 -13
  32. package/dist/src/FlinkLogFactory.d.ts +288 -0
  33. package/dist/src/FlinkLogFactory.js +619 -0
  34. package/dist/src/FlinkRepo.d.ts +10 -2
  35. package/dist/src/FlinkRepo.js +11 -1
  36. package/dist/src/FlinkRequestContext.d.ts +63 -0
  37. package/dist/src/FlinkRequestContext.js +74 -0
  38. package/dist/src/FlinkResponse.d.ts +6 -0
  39. package/dist/src/FlinkService.d.ts +38 -0
  40. package/dist/src/FlinkService.js +46 -0
  41. package/dist/src/LeaderElection.d.ts +45 -0
  42. package/dist/src/LeaderElection.js +269 -0
  43. package/dist/src/SchemaCache.d.ts +84 -0
  44. package/dist/src/SchemaCache.js +289 -0
  45. package/dist/src/TypeScriptCompiler.d.ts +161 -51
  46. package/dist/src/TypeScriptCompiler.js +1253 -617
  47. package/dist/src/TypeScriptUtils.js +4 -0
  48. package/dist/src/ai/AgentRunner.d.ts +39 -0
  49. package/dist/src/ai/AgentRunner.js +760 -0
  50. package/dist/src/ai/ConversationAgent.d.ts +279 -0
  51. package/dist/src/ai/ConversationAgent.js +404 -0
  52. package/dist/src/ai/ConversationFlinkAgent.d.ts +278 -0
  53. package/dist/src/ai/ConversationFlinkAgent.js +404 -0
  54. package/dist/src/ai/FlinkAgent.d.ts +690 -0
  55. package/dist/src/ai/FlinkAgent.js +729 -0
  56. package/dist/src/ai/FlinkTool.d.ts +135 -0
  57. package/dist/src/ai/FlinkTool.js +2 -0
  58. package/dist/src/ai/InMemoryConversationAgent.d.ts +121 -0
  59. package/dist/src/ai/InMemoryConversationAgent.js +209 -0
  60. package/dist/src/ai/LLMAdapter.d.ts +148 -0
  61. package/dist/src/ai/LLMAdapter.js +2 -0
  62. package/dist/src/ai/PersistentFlinkAgent.d.ts +278 -0
  63. package/dist/src/ai/PersistentFlinkAgent.js +403 -0
  64. package/dist/src/ai/SubAgentExecutor.d.ts +38 -0
  65. package/dist/src/ai/SubAgentExecutor.js +223 -0
  66. package/dist/src/ai/ToolExecutor.d.ts +64 -0
  67. package/dist/src/ai/ToolExecutor.js +497 -0
  68. package/dist/src/ai/agentInstructions.d.ts +68 -0
  69. package/dist/src/ai/agentInstructions.js +286 -0
  70. package/dist/src/ai/index.d.ts +8 -0
  71. package/dist/src/ai/index.js +26 -0
  72. package/dist/src/ai/instructionFileLoader.d.ts +44 -0
  73. package/dist/src/ai/instructionFileLoader.js +179 -0
  74. package/dist/src/auth/FlinkAuthPlugin.d.ts +1 -1
  75. package/dist/src/handlers/StreamWriterFactory.d.ts +20 -0
  76. package/dist/src/handlers/StreamWriterFactory.js +83 -0
  77. package/dist/src/index.d.ts +14 -0
  78. package/dist/src/index.js +17 -0
  79. package/dist/src/loadPluginSchemas.d.ts +45 -0
  80. package/dist/src/loadPluginSchemas.js +143 -0
  81. package/dist/src/schema-extraction/ComplexTypeDetection.d.ts +40 -0
  82. package/dist/src/schema-extraction/ComplexTypeDetection.js +75 -0
  83. package/dist/src/schema-extraction/TypeScriptSourceParser.d.ts +321 -0
  84. package/dist/src/schema-extraction/TypeScriptSourceParser.js +925 -0
  85. package/dist/src/schema-extraction/TypeScriptSourceParser.spec.d.ts +1 -0
  86. package/dist/src/schema-extraction/TypeScriptSourceParser.spec.js +233 -0
  87. package/dist/src/schema-extraction/TypeScriptTokenizer.d.ts +57 -0
  88. package/dist/src/schema-extraction/TypeScriptTokenizer.js +177 -0
  89. package/dist/src/schema-extraction/index.d.ts +2 -0
  90. package/dist/src/schema-extraction/index.js +20 -0
  91. package/dist/src/schema-extraction/types.d.ts +31 -0
  92. package/dist/src/schema-extraction/types.js +2 -0
  93. package/dist/src/utils/loadFlinkConfig.d.ts +53 -0
  94. package/dist/src/utils/loadFlinkConfig.js +77 -0
  95. package/dist/src/utils.d.ts +30 -0
  96. package/dist/src/utils.js +52 -0
  97. package/dist/src/workers/SchemaGeneratorWorker.d.ts +1 -0
  98. package/dist/src/workers/SchemaGeneratorWorker.js +49 -0
  99. package/dist/src/workers/WorkerPool.d.ts +60 -0
  100. package/dist/src/workers/WorkerPool.js +306 -0
  101. package/examples/logging-hierarchical-example.ts +125 -0
  102. package/package.json +27 -4
  103. package/readme.md +499 -0
  104. package/spec/AgentDescendantDetection.spec.ts +335 -0
  105. package/spec/AgentDuplicateDetection.spec.ts +112 -0
  106. package/spec/AgentObserver.spec.ts +266 -0
  107. package/spec/AgentRunner.spec.ts +1062 -0
  108. package/spec/AsyncLocalStorageContext.spec.ts +223 -0
  109. package/spec/ConversationHooks.spec.ts +257 -0
  110. package/spec/FlinkAgent.spec.ts +681 -0
  111. package/spec/FlinkApp.htmlResponse.spec.ts +260 -0
  112. package/spec/FlinkApp.onError.invocation.spec.ts +151 -0
  113. package/spec/FlinkApp.onError.spec.ts +1 -2
  114. package/spec/FlinkApp.routeOrdering.spec.ts +61 -0
  115. package/spec/FlinkApp.undefinedResponse.spec.ts +123 -0
  116. package/spec/FlinkJob.spec.ts +171 -0
  117. package/spec/FlinkLogFactory.spec.ts +337 -0
  118. package/spec/FlinkRepo.spec.ts +1 -1
  119. package/spec/LeaderElection.spec.ts +174 -0
  120. package/spec/StreamingIntegration.spec.ts +139 -0
  121. package/spec/ToolExecutor.spec.ts +465 -0
  122. package/spec/TypeScriptCompiler.spec.ts +1 -1
  123. package/spec/TypeScriptSourceParser.spec.ts +1215 -0
  124. package/spec/TypeScriptTokenizer.spec.ts +366 -0
  125. package/spec/ai/ContextCompaction.spec.ts +405 -0
  126. package/spec/ai/ConversationAgent.spec.ts +520 -0
  127. package/spec/ai/InMemoryConversationAgent.spec.ts +144 -0
  128. package/spec/ai/agentInstructions.spec.ts +358 -0
  129. package/spec/fixtures/agent-instructions/TestAgent.ts +24 -0
  130. package/spec/fixtures/agent-instructions/simple.md +3 -0
  131. package/spec/fixtures/agent-instructions/template.md +18 -0
  132. package/spec/fixtures/agent-instructions/yaml-format.yaml +9 -0
  133. package/spec/mock-project/dist/.tsbuildinfo +1 -0
  134. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCar.js +56 -0
  135. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCar2.js +58 -0
  136. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema.js +52 -0
  137. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema2.js +52 -0
  138. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema3.js +52 -0
  139. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithLiteralSchema.js +54 -0
  140. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithLiteralSchema2.js +54 -0
  141. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithSchemaInFile.js +57 -0
  142. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithSchemaInFile2.js +57 -0
  143. package/spec/mock-project/dist/spec/mock-project/src/handlers/ManuallyAddedHandler.js +53 -0
  144. package/spec/mock-project/dist/spec/mock-project/src/handlers/ManuallyAddedHandler2.js +55 -0
  145. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchCar.js +57 -0
  146. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchOnboardingSession.js +75 -0
  147. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchOrderWithComplexTypes.js +57 -0
  148. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchProductWithIntersection.js +58 -0
  149. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchUserWithUnion.js +58 -0
  150. package/spec/mock-project/dist/spec/mock-project/src/handlers/PostCar.js +54 -0
  151. package/spec/mock-project/dist/spec/mock-project/src/handlers/PostLogin.js +55 -0
  152. package/spec/mock-project/dist/spec/mock-project/src/handlers/PostLogout.js +54 -0
  153. package/spec/mock-project/dist/spec/mock-project/src/handlers/PutCar.js +54 -0
  154. package/spec/mock-project/dist/spec/mock-project/src/index.js +83 -0
  155. package/spec/mock-project/dist/spec/mock-project/src/repos/CarRepo.js +26 -0
  156. package/spec/mock-project/dist/spec/mock-project/src/schemas/Car.js +2 -0
  157. package/spec/mock-project/dist/spec/mock-project/src/schemas/DefaultExportSchema.js +2 -0
  158. package/spec/mock-project/dist/spec/mock-project/src/schemas/FileWithTwoSchemas.js +2 -0
  159. package/spec/mock-project/dist/src/FlinkApp.js +1000 -0
  160. package/spec/mock-project/dist/src/FlinkContext.js +2 -0
  161. package/spec/mock-project/dist/src/FlinkErrors.js +143 -0
  162. package/spec/mock-project/dist/src/FlinkHttpHandler.js +47 -0
  163. package/spec/mock-project/dist/src/FlinkJob.js +2 -0
  164. package/spec/mock-project/dist/src/FlinkLog.js +119 -0
  165. package/spec/mock-project/dist/src/FlinkLogFactory.js +617 -0
  166. package/spec/mock-project/dist/src/FlinkPlugin.js +2 -0
  167. package/spec/mock-project/dist/src/FlinkRepo.js +224 -0
  168. package/spec/mock-project/dist/src/FlinkRequestContext.js +74 -0
  169. package/spec/mock-project/dist/src/FlinkResponse.js +2 -0
  170. package/spec/mock-project/dist/src/ai/AgentExecutor.js +279 -0
  171. package/spec/mock-project/dist/src/ai/AgentRunner.js +632 -0
  172. package/spec/mock-project/dist/src/ai/ConversationAgent.js +402 -0
  173. package/spec/mock-project/dist/src/ai/ConversationFlinkAgent.js +422 -0
  174. package/spec/mock-project/dist/src/ai/FlinkAgent.js +699 -0
  175. package/spec/mock-project/dist/src/ai/FlinkTool.js +2 -0
  176. package/spec/mock-project/dist/src/ai/InMemoryConversationAgent.js +209 -0
  177. package/spec/mock-project/dist/src/ai/LLMAdapter.js +2 -0
  178. package/spec/mock-project/dist/src/ai/SubAgentExecutor.js +223 -0
  179. package/spec/mock-project/dist/src/ai/ToolExecutor.js +412 -0
  180. package/spec/mock-project/dist/src/ai/agentInstructions.js +246 -0
  181. package/spec/mock-project/dist/src/auth/FlinkAuthPlugin.js +2 -0
  182. package/spec/mock-project/dist/src/auth/FlinkAuthUser.js +2 -0
  183. package/spec/mock-project/dist/src/handlers/GetCar.js +26 -52
  184. package/spec/mock-project/dist/src/handlers/GetCar.js.map +1 -0
  185. package/spec/mock-project/dist/src/handlers/GetCar2.js +32 -54
  186. package/spec/mock-project/dist/src/handlers/GetCar2.js.map +1 -0
  187. package/spec/mock-project/dist/src/handlers/GetCarWithArraySchema.js +26 -48
  188. package/spec/mock-project/dist/src/handlers/GetCarWithArraySchema.js.map +1 -0
  189. package/spec/mock-project/dist/src/handlers/GetCarWithArraySchema2.js +28 -48
  190. package/spec/mock-project/dist/src/handlers/GetCarWithArraySchema2.js.map +1 -0
  191. package/spec/mock-project/dist/src/handlers/GetCarWithArraySchema3.js +29 -48
  192. package/spec/mock-project/dist/src/handlers/GetCarWithArraySchema3.js.map +1 -0
  193. package/spec/mock-project/dist/src/handlers/GetCarWithLiteralSchema.js +26 -50
  194. package/spec/mock-project/dist/src/handlers/GetCarWithLiteralSchema.js.map +1 -0
  195. package/spec/mock-project/dist/src/handlers/GetCarWithLiteralSchema2.js +28 -50
  196. package/spec/mock-project/dist/src/handlers/GetCarWithLiteralSchema2.js.map +1 -0
  197. package/spec/mock-project/dist/src/handlers/GetCarWithSchemaInFile.js +27 -53
  198. package/spec/mock-project/dist/src/handlers/GetCarWithSchemaInFile.js.map +1 -0
  199. package/spec/mock-project/dist/src/handlers/GetCarWithSchemaInFile2.js +29 -53
  200. package/spec/mock-project/dist/src/handlers/GetCarWithSchemaInFile2.js.map +1 -0
  201. package/spec/mock-project/dist/src/handlers/ManuallyAddedHandler.js +16 -49
  202. package/spec/mock-project/dist/src/handlers/ManuallyAddedHandler.js.map +1 -0
  203. package/spec/mock-project/dist/src/handlers/ManuallyAddedHandler2.js +25 -50
  204. package/spec/mock-project/dist/src/handlers/ManuallyAddedHandler2.js.map +1 -0
  205. package/spec/mock-project/dist/src/handlers/PatchCar.js +27 -53
  206. package/spec/mock-project/dist/src/handlers/PatchCar.js.map +1 -0
  207. package/spec/mock-project/dist/src/handlers/PatchOnboardingSession.js +44 -70
  208. package/spec/mock-project/dist/src/handlers/PatchOnboardingSession.js.map +1 -0
  209. package/spec/mock-project/dist/src/handlers/PatchOrderWithComplexTypes.js +27 -53
  210. package/spec/mock-project/dist/src/handlers/PatchOrderWithComplexTypes.js.map +1 -0
  211. package/spec/mock-project/dist/src/handlers/PatchProductWithIntersection.js +28 -54
  212. package/spec/mock-project/dist/src/handlers/PatchProductWithIntersection.js.map +1 -0
  213. package/spec/mock-project/dist/src/handlers/PatchUserWithUnion.js +28 -54
  214. package/spec/mock-project/dist/src/handlers/PatchUserWithUnion.js.map +1 -0
  215. package/spec/mock-project/dist/src/handlers/PostCar.js +24 -50
  216. package/spec/mock-project/dist/src/handlers/PostCar.js.map +1 -0
  217. package/spec/mock-project/dist/src/handlers/PostLogin.js +25 -51
  218. package/spec/mock-project/dist/src/handlers/PostLogin.js.map +1 -0
  219. package/spec/mock-project/dist/src/handlers/PostLogout.js +24 -50
  220. package/spec/mock-project/dist/src/handlers/PostLogout.js.map +1 -0
  221. package/spec/mock-project/dist/src/handlers/PutCar.js +24 -50
  222. package/spec/mock-project/dist/src/handlers/PutCar.js.map +1 -0
  223. package/spec/mock-project/dist/src/handlers/StreamWriterFactory.js +83 -0
  224. package/spec/mock-project/dist/src/index.js +52 -76
  225. package/spec/mock-project/dist/src/index.js.map +1 -0
  226. package/spec/mock-project/dist/src/mock-data-generator.js +9 -0
  227. package/spec/mock-project/dist/src/repos/CarRepo.js +12 -24
  228. package/spec/mock-project/dist/src/repos/CarRepo.js.map +1 -0
  229. package/spec/mock-project/dist/src/schemas/Car.js +3 -1
  230. package/spec/mock-project/dist/src/schemas/Car.js.map +1 -0
  231. package/spec/mock-project/dist/src/schemas/DefaultExportSchema.js +3 -1
  232. package/spec/mock-project/dist/src/schemas/DefaultExportSchema.js.map +1 -0
  233. package/spec/mock-project/dist/src/schemas/FileWithTwoSchemas.js +3 -1
  234. package/spec/mock-project/dist/src/schemas/FileWithTwoSchemas.js.map +1 -0
  235. package/spec/mock-project/dist/src/utils.js +290 -0
  236. package/spec/mock-project/tsconfig.json +6 -1
  237. package/spec/schema-generation-nested-objects.spec.ts +97 -0
  238. package/spec/testHelpers.ts +49 -0
  239. package/spec/utils.caseConversion.spec.ts +78 -0
  240. package/spec/utils.spec.ts +13 -13
  241. package/src/DependencyTracker.ts +166 -0
  242. package/src/FlinkApp.ts +895 -154
  243. package/src/FlinkContext.ts +43 -0
  244. package/src/FlinkErrors.ts +32 -12
  245. package/src/FlinkHttpHandler.ts +182 -20
  246. package/src/FlinkJob.ts +11 -0
  247. package/src/FlinkLog.ts +119 -12
  248. package/src/FlinkLogFactory.ts +699 -0
  249. package/src/FlinkRepo.ts +10 -3
  250. package/src/FlinkRequestContext.ts +95 -0
  251. package/src/FlinkResponse.ts +6 -0
  252. package/src/FlinkService.ts +49 -0
  253. package/src/LeaderElection.ts +203 -0
  254. package/src/SchemaCache.ts +232 -0
  255. package/src/TypeScriptCompiler.ts +1347 -610
  256. package/src/TypeScriptUtils.ts +5 -0
  257. package/src/ai/AgentRunner.ts +646 -0
  258. package/src/ai/ConversationAgent.ts +413 -0
  259. package/src/ai/FlinkAgent.ts +1069 -0
  260. package/src/ai/FlinkTool.ts +165 -0
  261. package/src/ai/InMemoryConversationAgent.ts +149 -0
  262. package/src/ai/LLMAdapter.ts +126 -0
  263. package/src/ai/ToolExecutor.ts +485 -0
  264. package/src/ai/agentInstructions.ts +245 -0
  265. package/src/ai/index.ts +8 -0
  266. package/src/ai/instructionFileLoader.ts +156 -0
  267. package/src/auth/FlinkAuthPlugin.ts +2 -1
  268. package/src/handlers/StreamWriterFactory.ts +84 -0
  269. package/src/index.ts +14 -0
  270. package/src/loadPluginSchemas.ts +141 -0
  271. package/src/schema-extraction/TypeScriptSourceParser.ts +1058 -0
  272. package/src/schema-extraction/TypeScriptTokenizer.ts +205 -0
  273. package/src/schema-extraction/index.ts +2 -0
  274. package/src/schema-extraction/types.ts +34 -0
  275. package/src/utils/loadFlinkConfig.ts +89 -0
  276. package/src/utils.ts +52 -0
  277. package/tsconfig.json +6 -1
@@ -0,0 +1,171 @@
1
+ import { FlinkApp, autoRegisteredJobs } from "../src/FlinkApp";
2
+ import { FlinkContext } from "../src/FlinkContext";
3
+ import { FlinkJobFile } from "../src/FlinkJob";
4
+ import { FlinkLogFactory } from "../src/FlinkLogFactory";
5
+
6
+ interface TestContext extends FlinkContext {}
7
+
8
+ describe("FlinkJob error handling", () => {
9
+ let app: FlinkApp<TestContext>;
10
+ let consoleErrorSpy: jasmine.Spy;
11
+
12
+ beforeEach(() => {
13
+ consoleErrorSpy = spyOn(console, "error");
14
+ });
15
+
16
+ afterEach(async () => {
17
+ autoRegisteredJobs.length = 0;
18
+ if (app?.started) {
19
+ await app.stop();
20
+ }
21
+ });
22
+
23
+ it("should catch and log errors from afterDelay 0ms jobs without crashing", async () => {
24
+ const job: FlinkJobFile = {
25
+ Job: { id: "failing-job-0ms", afterDelay: "0ms" },
26
+ default: async () => {
27
+ throw new Error("Job error 0ms");
28
+ },
29
+ };
30
+
31
+ autoRegisteredJobs.push(job);
32
+
33
+ app = new FlinkApp<TestContext>({ name: "test-job-errors-0ms", disableHttpServer: true });
34
+ await app.start();
35
+
36
+ await new Promise((resolve) => setTimeout(resolve, 50));
37
+
38
+ expect(consoleErrorSpy).toHaveBeenCalled();
39
+ });
40
+
41
+ it("should catch and log errors from afterDelay jobs without crashing", async () => {
42
+ const job: FlinkJobFile = {
43
+ Job: { id: "failing-job-delay", afterDelay: "10ms" },
44
+ default: async () => {
45
+ throw new Error("Job error with delay");
46
+ },
47
+ };
48
+
49
+ autoRegisteredJobs.push(job);
50
+
51
+ app = new FlinkApp<TestContext>({ name: "test-job-errors-delay", disableHttpServer: true });
52
+ await app.start();
53
+
54
+ await new Promise((resolve) => setTimeout(resolve, 100));
55
+
56
+ expect(consoleErrorSpy).toHaveBeenCalled();
57
+ });
58
+
59
+ it("should catch and log errors from interval jobs without crashing", async () => {
60
+ const job: FlinkJobFile = {
61
+ Job: { id: "failing-interval-job", interval: "10ms" },
62
+ default: async () => {
63
+ throw new Error("Interval job error");
64
+ },
65
+ };
66
+
67
+ autoRegisteredJobs.push(job);
68
+
69
+ app = new FlinkApp<TestContext>({ name: "test-job-errors-interval", disableHttpServer: true });
70
+ await app.start();
71
+
72
+ await new Promise((resolve) => setTimeout(resolve, 100));
73
+
74
+ expect(consoleErrorSpy).toHaveBeenCalled();
75
+ });
76
+
77
+ it("should run afterDelay 0ms job exactly once", async () => {
78
+ let runCount = 0;
79
+
80
+ const job: FlinkJobFile = {
81
+ Job: { id: "once-job-0ms", afterDelay: "0ms" },
82
+ default: async () => {
83
+ runCount++;
84
+ },
85
+ };
86
+
87
+ autoRegisteredJobs.push(job);
88
+
89
+ app = new FlinkApp<TestContext>({ name: "test-job-once-0ms", disableHttpServer: true });
90
+ await app.start();
91
+
92
+ await new Promise((resolve) => setTimeout(resolve, 100));
93
+
94
+ expect(runCount).toBe(1);
95
+ });
96
+ });
97
+
98
+ describe("FlinkJob leader election with runOnAllInstances", () => {
99
+ let app: FlinkApp<TestContext>;
100
+
101
+ afterEach(async () => {
102
+ autoRegisteredJobs.length = 0;
103
+ if (app?.started) {
104
+ await app.stop();
105
+ }
106
+ });
107
+
108
+ it("should run all jobs when leader election is enabled but no db is configured", async () => {
109
+ const schedulerLog = FlinkLogFactory.createLogger("flink.scheduler");
110
+ const warnSpy = spyOn(schedulerLog, "warn");
111
+
112
+ let leaderJobRan = false;
113
+ let allInstanceJobRan = false;
114
+
115
+ autoRegisteredJobs.push({
116
+ Job: { id: "leader-only-job", afterDelay: "0ms" },
117
+ default: async () => {
118
+ leaderJobRan = true;
119
+ },
120
+ });
121
+
122
+ autoRegisteredJobs.push({
123
+ Job: { id: "all-instance-job", afterDelay: "0ms", runOnAllInstances: true },
124
+ default: async () => {
125
+ allInstanceJobRan = true;
126
+ },
127
+ });
128
+
129
+ app = new FlinkApp<TestContext>({
130
+ name: "test-leader-no-db",
131
+ disableHttpServer: true,
132
+ scheduling: { leaderElection: true },
133
+ });
134
+
135
+ await app.start();
136
+ await new Promise((resolve) => setTimeout(resolve, 50));
137
+
138
+ // Without db, falls back to running all jobs
139
+ expect(leaderJobRan).toBe(true);
140
+ expect(allInstanceJobRan).toBe(true);
141
+ expect(warnSpy).toHaveBeenCalled();
142
+ const warnMessage = warnSpy.calls.mostRecent().args[0];
143
+ expect(warnMessage).toContain("Leader election is enabled but no database is configured");
144
+ });
145
+
146
+ it("should not start scheduler for leader-only jobs when not leader", async () => {
147
+ // Without a real MongoDB, we can't fully test leader election.
148
+ // This test verifies that when leaderElection is enabled without db,
149
+ // the warning is shown and all jobs still run as a fallback.
150
+ let jobRanCount = 0;
151
+
152
+ autoRegisteredJobs.push({
153
+ Job: { id: "interval-job", interval: "50ms" },
154
+ default: async () => {
155
+ jobRanCount++;
156
+ },
157
+ });
158
+
159
+ app = new FlinkApp<TestContext>({
160
+ name: "test-no-db-fallback",
161
+ disableHttpServer: true,
162
+ scheduling: { leaderElection: true },
163
+ });
164
+
165
+ await app.start();
166
+ await new Promise((resolve) => setTimeout(resolve, 200));
167
+
168
+ // Should have run at least once since it falls back without db
169
+ expect(jobRanCount).toBeGreaterThan(0);
170
+ });
171
+ });
@@ -0,0 +1,337 @@
1
+ import { FlinkLogFactory } from "../src/FlinkLogFactory";
2
+
3
+ describe("FlinkLogFactory - Hierarchical Prefix Matching", () => {
4
+ beforeEach(() => {
5
+ // Reset factory state before each test
6
+ FlinkLogFactory.resetComponentLevels();
7
+ FlinkLogFactory.resetHierarchicalLevels();
8
+ FlinkLogFactory.resetWildcardLevels();
9
+ FlinkLogFactory.setGlobalLevel("info");
10
+ });
11
+
12
+ describe("Basic Prefix Matching (Java-style)", () => {
13
+ it("should match flink.ai.* prefix", () => {
14
+ FlinkLogFactory.setHierarchicalLevel("flink.ai", "debug");
15
+
16
+ const openaiLog = FlinkLogFactory.createLogger("flink.ai.openai");
17
+ const anthropicLog = FlinkLogFactory.createLogger("flink.ai.anthropic");
18
+ const dbLog = FlinkLogFactory.createLogger("flink.database.mongodb");
19
+
20
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("debug");
21
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.anthropic")).toBe("debug");
22
+ expect(FlinkLogFactory.getEffectiveLevel("flink.database.mongodb")).toBeNull(); // Falls back to global
23
+ });
24
+
25
+ it("should match multi-level prefixes", () => {
26
+ FlinkLogFactory.setHierarchicalLevel("flink.ai", "debug");
27
+
28
+ const deepLog = FlinkLogFactory.createLogger("flink.ai.openai.v4.gpt");
29
+
30
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai.v4.gpt")).toBe("debug");
31
+ });
32
+
33
+ it("should handle prefix with trailing dot", () => {
34
+ FlinkLogFactory.setHierarchicalLevel("flink.ai.", "debug");
35
+
36
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("debug");
37
+ });
38
+
39
+ it("should handle prefix without trailing dot", () => {
40
+ FlinkLogFactory.setHierarchicalLevel("flink.ai", "debug");
41
+
42
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("debug");
43
+ });
44
+ });
45
+
46
+ describe("Case Insensitivity", () => {
47
+ it("should normalize logger names to lowercase", () => {
48
+ FlinkLogFactory.setHierarchicalLevel("flink.ai", "debug");
49
+
50
+ const log1 = FlinkLogFactory.createLogger("flink.ai.openai");
51
+ const log2 = FlinkLogFactory.createLogger("Flink.AI.OpenAI");
52
+ const log3 = FlinkLogFactory.createLogger("FLINK.AI.OPENAI");
53
+
54
+ // All should return the same logger instance
55
+ expect(log1).toBe(log2);
56
+ expect(log2).toBe(log3);
57
+ });
58
+
59
+ it("should resolve levels case-insensitively", () => {
60
+ FlinkLogFactory.setHierarchicalLevel("Flink.AI", "debug");
61
+
62
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("debug");
63
+ expect(FlinkLogFactory.getEffectiveLevel("Flink.AI.OpenAI")).toBe("debug");
64
+ expect(FlinkLogFactory.getEffectiveLevel("FLINK.AI.OPENAI")).toBe("debug");
65
+ });
66
+ });
67
+
68
+ describe("Precedence Rules", () => {
69
+ it("should prefer exact match over prefix", () => {
70
+ FlinkLogFactory.setHierarchicalLevel("flink.ai", "debug");
71
+ FlinkLogFactory.setComponentLevel("flink.ai.openai", "trace");
72
+
73
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("trace"); // Exact
74
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.anthropic")).toBe("debug"); // Prefix
75
+ });
76
+
77
+ it("should prefer more specific prefix over less specific", () => {
78
+ FlinkLogFactory.setGlobalLevel("warn");
79
+ FlinkLogFactory.setHierarchicalLevel("flink", "info");
80
+ FlinkLogFactory.setHierarchicalLevel("flink.ai", "debug");
81
+ FlinkLogFactory.setHierarchicalLevel("flink.ai.openai", "trace");
82
+
83
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai.v4")).toBe("trace"); // Most specific: flink.ai.openai
84
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.claude")).toBe("debug"); // flink.ai
85
+ expect(FlinkLogFactory.getEffectiveLevel("flink.database.mongodb")).toBe("info"); // flink
86
+ expect(FlinkLogFactory.getEffectiveLevel("other.service")).toBeNull(); // Global fallback
87
+ });
88
+
89
+ it("should handle overlapping prefixes correctly", () => {
90
+ FlinkLogFactory.setHierarchicalLevel("flink", "warn");
91
+ FlinkLogFactory.setHierarchicalLevel("flink.ai", "debug");
92
+
93
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("debug"); // More specific
94
+ expect(FlinkLogFactory.getEffectiveLevel("flink.database")).toBe("warn"); // Less specific
95
+ });
96
+ });
97
+
98
+ describe("Wildcard Patterns", () => {
99
+ it("should support single-level wildcard (*)", () => {
100
+ FlinkLogFactory.setWildcardLevel("flink.ai.*", "debug");
101
+
102
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("debug"); // Matches
103
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai.v4")).toBeNull(); // Too deep
104
+ });
105
+
106
+ it("should support multi-level wildcard (**)", () => {
107
+ FlinkLogFactory.setWildcardLevel("flink.ai.**", "trace");
108
+
109
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("trace");
110
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai.v4")).toBe("trace");
111
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai.v4.gpt")).toBe("trace");
112
+ });
113
+
114
+ it("should support partial segment wildcard", () => {
115
+ FlinkLogFactory.setWildcardLevel("flink.database.mongo*", "warn");
116
+
117
+ expect(FlinkLogFactory.getEffectiveLevel("flink.database.mongodb")).toBe("warn");
118
+ expect(FlinkLogFactory.getEffectiveLevel("flink.database.mongoose")).toBe("warn");
119
+ expect(FlinkLogFactory.getEffectiveLevel("flink.database.redis")).toBeNull();
120
+ });
121
+
122
+ it("should prefer prefix match over wildcard", () => {
123
+ FlinkLogFactory.setHierarchicalLevel("flink.ai", "debug");
124
+ FlinkLogFactory.setWildcardLevel("flink.ai.*", "trace");
125
+
126
+ // Prefix is checked first and matches (more intuitive)
127
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("debug");
128
+ });
129
+ });
130
+
131
+ describe("Environment Variable Parsing", () => {
132
+ beforeEach(() => {
133
+ // Clear environment
134
+ delete process.env.LOG_LEVEL;
135
+ });
136
+
137
+ it("should parse LOG_LEVEL env var for global level", () => {
138
+ process.env.LOG_LEVEL = "debug";
139
+
140
+ // Reset to force re-initialization
141
+ (FlinkLogFactory as any).initialized = false;
142
+ FlinkLogFactory.configure();
143
+
144
+ expect(FlinkLogFactory.getGlobalLevel()).toBe("debug");
145
+ });
146
+
147
+ it("should ignore invalid LOG_LEVEL values", () => {
148
+ process.env.LOG_LEVEL = "invalid";
149
+
150
+ (FlinkLogFactory as any).initialized = false;
151
+ FlinkLogFactory.configure();
152
+
153
+ expect(FlinkLogFactory.getGlobalLevel()).toBe("info"); // Default
154
+ });
155
+
156
+ it("should be case-insensitive", () => {
157
+ process.env.LOG_LEVEL = "DEBUG";
158
+
159
+ (FlinkLogFactory as any).initialized = false;
160
+ FlinkLogFactory.configure();
161
+
162
+ expect(FlinkLogFactory.getGlobalLevel()).toBe("debug");
163
+ });
164
+ });
165
+
166
+ describe("Config File Loading", () => {
167
+ it("should parse components map with wildcards", () => {
168
+ const config = {
169
+ global: "info" as const,
170
+ showTimestamps: false,
171
+ components: {
172
+ "flink.ai.openai": "trace" as const,
173
+ "flink.ai.*": "debug" as const,
174
+ "flink.database.**": "warn" as const,
175
+ "flink.handlers.": "info" as const
176
+ }
177
+ };
178
+
179
+ (FlinkLogFactory as any).initialized = false;
180
+ FlinkLogFactory.configure(config);
181
+
182
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("trace");
183
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.anthropic")).toBe("debug");
184
+ expect(FlinkLogFactory.getEffectiveLevel("flink.database.mongodb.connection")).toBe("warn");
185
+ expect(FlinkLogFactory.getEffectiveLevel("flink.handlers.car")).toBe("info");
186
+ expect(FlinkLogFactory.getShowTimestamps()).toBe(false);
187
+ });
188
+
189
+ it("should prioritize config file over env var", () => {
190
+ process.env.LOG_LEVEL = "error";
191
+
192
+ const config = {
193
+ global: "debug" as const,
194
+ components: {}
195
+ };
196
+
197
+ (FlinkLogFactory as any).initialized = false;
198
+ FlinkLogFactory.configure(config);
199
+
200
+ expect(FlinkLogFactory.getGlobalLevel()).toBe("debug"); // Config wins
201
+ });
202
+ });
203
+
204
+ describe("Programmatic API", () => {
205
+ it("should allow setting hierarchical levels", () => {
206
+ FlinkLogFactory.setHierarchicalLevel("flink.ai", "debug");
207
+
208
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("debug");
209
+ });
210
+
211
+ it("should allow setting wildcard levels", () => {
212
+ FlinkLogFactory.setWildcardLevel("flink.ai.*", "trace");
213
+
214
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("trace");
215
+ });
216
+
217
+ it("should allow setting exact levels", () => {
218
+ FlinkLogFactory.setComponentLevel("flink.ai.openai", "trace");
219
+
220
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("trace");
221
+ });
222
+
223
+ it("should allow clearing exact levels", () => {
224
+ FlinkLogFactory.setComponentLevel("flink.ai.openai", "trace");
225
+ FlinkLogFactory.setComponentLevel("flink.ai.openai", null);
226
+
227
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBeNull();
228
+ });
229
+
230
+ it("should allow resetting hierarchical levels", () => {
231
+ FlinkLogFactory.setHierarchicalLevel("flink.ai", "debug");
232
+ FlinkLogFactory.resetHierarchicalLevels();
233
+
234
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBeNull();
235
+ });
236
+
237
+ it("should allow resetting wildcard levels", () => {
238
+ FlinkLogFactory.setWildcardLevel("flink.ai.*", "debug");
239
+ FlinkLogFactory.resetWildcardLevels();
240
+
241
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBeNull();
242
+ });
243
+ });
244
+
245
+ describe("Edge Cases", () => {
246
+ it("should handle empty segments in pattern", () => {
247
+ // Double dots should be normalized
248
+ FlinkLogFactory.setHierarchicalLevel("flink..ai", "debug");
249
+
250
+ // Should not match due to empty segment
251
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBeNull();
252
+ });
253
+
254
+ it("should handle single segment names", () => {
255
+ FlinkLogFactory.setComponentLevel("performance", "debug");
256
+
257
+ expect(FlinkLogFactory.getEffectiveLevel("performance")).toBe("debug");
258
+ });
259
+
260
+ it("should handle flink prefix requirement", () => {
261
+ FlinkLogFactory.setHierarchicalLevel("flink.ai", "debug");
262
+
263
+ // Without flink prefix
264
+ expect(FlinkLogFactory.getEffectiveLevel("ai.openai")).toBeNull();
265
+
266
+ // With flink prefix
267
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("debug");
268
+ });
269
+
270
+ it("should not match partial prefix", () => {
271
+ FlinkLogFactory.setHierarchicalLevel("flink.ai", "debug");
272
+
273
+ // Should not match "flink.air" - must be followed by dot
274
+ expect(FlinkLogFactory.getEffectiveLevel("flink.airline")).toBeNull();
275
+ });
276
+
277
+ it("should handle exact match for name with dots", () => {
278
+ FlinkLogFactory.setComponentLevel("flink.ai.openai", "trace");
279
+
280
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("trace");
281
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai.v4")).toBeNull();
282
+ });
283
+ });
284
+
285
+ describe("Specificity Ordering", () => {
286
+ it("should sort hierarchical configs by specificity", () => {
287
+ FlinkLogFactory.setHierarchicalLevel("flink", "warn");
288
+ FlinkLogFactory.setHierarchicalLevel("flink.ai.openai", "trace");
289
+ FlinkLogFactory.setHierarchicalLevel("flink.ai", "debug");
290
+
291
+ // Most specific should win
292
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai.v4")).toBe("trace");
293
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.claude")).toBe("debug");
294
+ expect(FlinkLogFactory.getEffectiveLevel("flink.database")).toBe("warn");
295
+ });
296
+
297
+ it("should sort wildcard configs by specificity", () => {
298
+ FlinkLogFactory.setWildcardLevel("flink.*", "warn");
299
+ FlinkLogFactory.setWildcardLevel("flink.ai.**", "trace");
300
+ FlinkLogFactory.setWildcardLevel("flink.ai.*", "debug");
301
+
302
+ // More specific wildcard should win
303
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("debug"); // flink.ai.* (2 segments)
304
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai.v4")).toBe("trace"); // flink.ai.** (2 segments, multi-level)
305
+ });
306
+ });
307
+
308
+ describe("Real-World Scenarios", () => {
309
+ it("should handle typical development setup", () => {
310
+ FlinkLogFactory.setGlobalLevel("warn");
311
+ FlinkLogFactory.setHierarchicalLevel("flink.ai", "debug");
312
+ FlinkLogFactory.setComponentLevel("flink.ai.openai", "trace");
313
+
314
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("trace"); // Trace for OpenAI
315
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.anthropic")).toBe("debug"); // Debug for other AI
316
+ expect(FlinkLogFactory.getEffectiveLevel("flink.database.mongodb")).toBeNull(); // Warn (global)
317
+ });
318
+
319
+ it("should handle production debugging", () => {
320
+ FlinkLogFactory.setGlobalLevel("error");
321
+ FlinkLogFactory.setHierarchicalLevel("flink.ai.openai", "debug");
322
+
323
+ // Only OpenAI components get debug, everything else is error
324
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("debug");
325
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai.streaming")).toBe("debug");
326
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.anthropic")).toBeNull(); // Error (global)
327
+ });
328
+
329
+ it("should handle test environment", () => {
330
+ FlinkLogFactory.setHierarchicalLevel("flink.ai", "trace");
331
+ FlinkLogFactory.setHierarchicalLevel("flink.database", "debug");
332
+
333
+ expect(FlinkLogFactory.getEffectiveLevel("flink.ai.openai")).toBe("trace");
334
+ expect(FlinkLogFactory.getEffectiveLevel("flink.database.mongodb")).toBe("debug");
335
+ });
336
+ });
337
+ });
@@ -60,7 +60,7 @@ describe("FlinkRepo", () => {
60
60
  it("should update document", async () => {
61
61
  const createdDoc = await repo.create({ name: "bar" });
62
62
 
63
- const updatedDoc = await repo.updateOne(createdDoc._id + "", {
63
+ const updatedDoc = await repo.updateById(createdDoc._id + "", {
64
64
  name: "foo",
65
65
  "nested.field": 1,
66
66
  });
@@ -0,0 +1,174 @@
1
+ import { LeaderElection } from "../src/LeaderElection";
2
+
3
+ describe("LeaderElection", () => {
4
+ let mockCollection: any;
5
+ let mockDb: any;
6
+
7
+ beforeEach(() => {
8
+ mockCollection = {
9
+ createIndex: jasmine.createSpy("createIndex").and.resolveTo(undefined),
10
+ findOneAndUpdate: jasmine.createSpy("findOneAndUpdate"),
11
+ deleteOne: jasmine.createSpy("deleteOne").and.resolveTo(undefined),
12
+ };
13
+
14
+ mockDb = {
15
+ collection: jasmine.createSpy("collection").and.returnValue(mockCollection),
16
+ };
17
+ });
18
+
19
+ it("should create collection with provided name", () => {
20
+ new LeaderElection(mockDb, { collectionName: "_my_leader" });
21
+ expect(mockDb.collection).toHaveBeenCalledWith("_my_leader");
22
+ });
23
+
24
+ it("should use default collection name", () => {
25
+ new LeaderElection(mockDb);
26
+ expect(mockDb.collection).toHaveBeenCalledWith("_flink_leader");
27
+ });
28
+
29
+ it("should create TTL index on start", async () => {
30
+ const le = new LeaderElection(mockDb, { leaseDurationMs: 10000 });
31
+
32
+ mockCollection.findOneAndUpdate.and.resolveTo({
33
+ instanceId: "will-not-match",
34
+ });
35
+
36
+ await le.start(
37
+ () => {},
38
+ () => {}
39
+ );
40
+ await le.stop();
41
+
42
+ expect(mockCollection.createIndex).toHaveBeenCalledWith(
43
+ { lastHeartbeat: 1 },
44
+ { expireAfterSeconds: 20 } // 2x lease duration in seconds
45
+ );
46
+ });
47
+
48
+ it("should call onBecameLeader when claiming leadership", async () => {
49
+ const onBecameLeader = jasmine.createSpy("onBecameLeader");
50
+ const onLostLeadership = jasmine.createSpy("onLostLeadership");
51
+
52
+ const le = new LeaderElection(mockDb, {
53
+ leaseDurationMs: 10000,
54
+ heartbeatIntervalMs: 50000,
55
+ });
56
+
57
+ // findOneAndUpdate returns a document with our instanceId
58
+ // We need to intercept the instanceId set in the update
59
+ mockCollection.findOneAndUpdate.and.callFake((_filter: any, update: any) => {
60
+ return Promise.resolve({
61
+ instanceId: update.$set.instanceId,
62
+ });
63
+ });
64
+
65
+ await le.start(onBecameLeader, onLostLeadership);
66
+
67
+ expect(le.isLeader).toBe(true);
68
+ expect(onBecameLeader).toHaveBeenCalledTimes(1);
69
+ expect(onLostLeadership).not.toHaveBeenCalled();
70
+
71
+ await le.stop();
72
+ });
73
+
74
+ it("should not become leader when another instance holds the lock", async () => {
75
+ const onBecameLeader = jasmine.createSpy("onBecameLeader");
76
+ const onLostLeadership = jasmine.createSpy("onLostLeadership");
77
+
78
+ const le = new LeaderElection(mockDb, {
79
+ leaseDurationMs: 10000,
80
+ heartbeatIntervalMs: 50000,
81
+ });
82
+
83
+ // findOneAndUpdate returns a document with a different instanceId
84
+ mockCollection.findOneAndUpdate.and.resolveTo({
85
+ instanceId: "other-instance",
86
+ });
87
+
88
+ await le.start(onBecameLeader, onLostLeadership);
89
+
90
+ expect(le.isLeader).toBe(false);
91
+ expect(onBecameLeader).not.toHaveBeenCalled();
92
+
93
+ await le.stop();
94
+ });
95
+
96
+ it("should handle duplicate key error gracefully", async () => {
97
+ const onBecameLeader = jasmine.createSpy("onBecameLeader");
98
+ const onLostLeadership = jasmine.createSpy("onLostLeadership");
99
+
100
+ const le = new LeaderElection(mockDb, {
101
+ leaseDurationMs: 10000,
102
+ heartbeatIntervalMs: 50000,
103
+ });
104
+
105
+ const duplicateKeyError = new Error("E11000 duplicate key");
106
+ (duplicateKeyError as any).code = 11000;
107
+ mockCollection.findOneAndUpdate.and.rejectWith(duplicateKeyError);
108
+
109
+ await le.start(onBecameLeader, onLostLeadership);
110
+
111
+ expect(le.isLeader).toBe(false);
112
+ expect(onBecameLeader).not.toHaveBeenCalled();
113
+
114
+ await le.stop();
115
+ });
116
+
117
+ it("should release leadership on stop", async () => {
118
+ const le = new LeaderElection(mockDb, {
119
+ leaseDurationMs: 10000,
120
+ heartbeatIntervalMs: 50000,
121
+ });
122
+
123
+ mockCollection.findOneAndUpdate.and.callFake((_filter: any, update: any) => {
124
+ return Promise.resolve({
125
+ instanceId: update.$set.instanceId,
126
+ });
127
+ });
128
+
129
+ await le.start(
130
+ () => {},
131
+ () => {}
132
+ );
133
+
134
+ expect(le.isLeader).toBe(true);
135
+
136
+ await le.stop();
137
+
138
+ expect(mockCollection.deleteOne).toHaveBeenCalled();
139
+ expect(le.isLeader).toBe(false);
140
+ });
141
+
142
+ it("should call onLostLeadership when losing the lock", async () => {
143
+ const onBecameLeader = jasmine.createSpy("onBecameLeader");
144
+ const onLostLeadership = jasmine.createSpy("onLostLeadership");
145
+
146
+ const le = new LeaderElection(mockDb, {
147
+ leaseDurationMs: 10000,
148
+ heartbeatIntervalMs: 100,
149
+ });
150
+
151
+ let callCount = 0;
152
+ mockCollection.findOneAndUpdate.and.callFake((_filter: any, update: any) => {
153
+ callCount++;
154
+ if (callCount === 1) {
155
+ // First call: we become leader
156
+ return Promise.resolve({ instanceId: update.$set.instanceId });
157
+ }
158
+ // Subsequent calls: another instance took over
159
+ return Promise.resolve({ instanceId: "other-instance" });
160
+ });
161
+
162
+ await le.start(onBecameLeader, onLostLeadership);
163
+
164
+ expect(le.isLeader).toBe(true);
165
+
166
+ // Wait for next heartbeat cycle
167
+ await new Promise((resolve) => setTimeout(resolve, 200));
168
+
169
+ expect(onLostLeadership).toHaveBeenCalledTimes(1);
170
+ expect(le.isLeader).toBe(false);
171
+
172
+ await le.stop();
173
+ });
174
+ });