@superblocksteam/shared 0.9607.3 → 0.9608.1

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 (294) hide show
  1. package/dist/database-lifecycle/appDatabaseCompatibility.d.ts +49 -0
  2. package/dist/database-lifecycle/appDatabaseCompatibility.d.ts.map +1 -0
  3. package/dist/database-lifecycle/appDatabaseCompatibility.js +32 -0
  4. package/dist/database-lifecycle/appDatabaseCompatibility.js.map +1 -0
  5. package/dist/database-lifecycle/appDatabaseCompatibility.test.d.ts +2 -0
  6. package/dist/database-lifecycle/appDatabaseCompatibility.test.d.ts.map +1 -0
  7. package/dist/database-lifecycle/appDatabaseCompatibility.test.js +47 -0
  8. package/dist/database-lifecycle/appDatabaseCompatibility.test.js.map +1 -0
  9. package/dist/database-lifecycle/destructiveMigrations.d.ts +12 -0
  10. package/dist/database-lifecycle/destructiveMigrations.d.ts.map +1 -1
  11. package/dist/database-lifecycle/destructiveMigrations.js +114 -0
  12. package/dist/database-lifecycle/destructiveMigrations.js.map +1 -1
  13. package/dist/database-lifecycle/destructiveMigrations.test.js +79 -0
  14. package/dist/database-lifecycle/destructiveMigrations.test.js.map +1 -1
  15. package/dist/database-lifecycle/index.d.ts +46 -2
  16. package/dist/database-lifecycle/index.d.ts.map +1 -1
  17. package/dist/database-lifecycle/index.js +127 -1
  18. package/dist/database-lifecycle/index.js.map +1 -1
  19. package/dist/database-lifecycle/providerDiagnostics.test.d.ts +2 -0
  20. package/dist/database-lifecycle/providerDiagnostics.test.d.ts.map +1 -0
  21. package/dist/database-lifecycle/providerDiagnostics.test.js +119 -0
  22. package/dist/database-lifecycle/providerDiagnostics.test.js.map +1 -0
  23. package/dist/environment-compatibility/appEnvironmentCompatibility.d.ts +65 -0
  24. package/dist/environment-compatibility/appEnvironmentCompatibility.d.ts.map +1 -0
  25. package/dist/environment-compatibility/appEnvironmentCompatibility.js +87 -0
  26. package/dist/environment-compatibility/appEnvironmentCompatibility.js.map +1 -0
  27. package/dist/environment-compatibility/appEnvironmentCompatibility.test.d.ts +2 -0
  28. package/dist/environment-compatibility/appEnvironmentCompatibility.test.d.ts.map +1 -0
  29. package/dist/environment-compatibility/appEnvironmentCompatibility.test.js +285 -0
  30. package/dist/environment-compatibility/appEnvironmentCompatibility.test.js.map +1 -0
  31. package/dist/errors/http.d.ts +15 -4
  32. package/dist/errors/http.d.ts.map +1 -1
  33. package/dist/errors/http.js +25 -7
  34. package/dist/errors/http.js.map +1 -1
  35. package/dist/errors/http.test.d.ts +2 -0
  36. package/dist/errors/http.test.d.ts.map +1 -0
  37. package/dist/errors/http.test.js +28 -0
  38. package/dist/errors/http.test.js.map +1 -0
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +2 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/plugins/templates/restApiIntegration.d.ts.map +1 -1
  44. package/dist/plugins/templates/restApiIntegration.js +2 -0
  45. package/dist/plugins/templates/restApiIntegration.js.map +1 -1
  46. package/dist/plugins/templates/shared/auth.d.ts +3 -1
  47. package/dist/plugins/templates/shared/auth.d.ts.map +1 -1
  48. package/dist/plugins/templates/shared/auth.js +129 -17
  49. package/dist/plugins/templates/shared/auth.js.map +1 -1
  50. package/dist/plugins/templates/shared/auth.test.d.ts +2 -0
  51. package/dist/plugins/templates/shared/auth.test.d.ts.map +1 -0
  52. package/dist/plugins/templates/shared/auth.test.js +116 -0
  53. package/dist/plugins/templates/shared/auth.test.js.map +1 -0
  54. package/dist/plugins/templates/shared/restapiintegration.d.ts +2 -0
  55. package/dist/plugins/templates/shared/restapiintegration.d.ts.map +1 -1
  56. package/dist/plugins/templates/shared/restapiintegration.js +3 -1
  57. package/dist/plugins/templates/shared/restapiintegration.js.map +1 -1
  58. package/dist/socket/dist-entry.test.d.ts +2 -0
  59. package/dist/socket/dist-entry.test.d.ts.map +1 -0
  60. package/dist/socket/dist-entry.test.js +34 -0
  61. package/dist/socket/dist-entry.test.js.map +1 -0
  62. package/dist/socket/heartbeat.d.ts +258 -0
  63. package/dist/socket/heartbeat.d.ts.map +1 -0
  64. package/dist/socket/heartbeat.js +307 -0
  65. package/dist/socket/heartbeat.js.map +1 -0
  66. package/dist/socket/heartbeat.test.d.ts +2 -0
  67. package/dist/socket/heartbeat.test.d.ts.map +1 -0
  68. package/dist/socket/heartbeat.test.js +431 -0
  69. package/dist/socket/heartbeat.test.js.map +1 -0
  70. package/dist/socket/index.d.ts +1 -0
  71. package/dist/socket/index.d.ts.map +1 -1
  72. package/dist/socket/index.js +1 -0
  73. package/dist/socket/index.js.map +1 -1
  74. package/dist/socket/util.d.ts.map +1 -1
  75. package/dist/socket/util.js +15 -1
  76. package/dist/socket/util.js.map +1 -1
  77. package/dist/socket/util.test.js +12 -2
  78. package/dist/socket/util.test.js.map +1 -1
  79. package/dist/types/ai/index.d.ts +3 -0
  80. package/dist/types/ai/index.d.ts.map +1 -1
  81. package/dist/types/ai/index.js +3 -0
  82. package/dist/types/ai/index.js.map +1 -1
  83. package/dist/types/application/index.d.ts +12 -0
  84. package/dist/types/application/index.d.ts.map +1 -1
  85. package/dist/types/application/index.js.map +1 -1
  86. package/dist/types/codeSecurity/index.d.ts +55 -1
  87. package/dist/types/codeSecurity/index.d.ts.map +1 -1
  88. package/dist/types/codeSecurity/index.js +21 -1
  89. package/dist/types/codeSecurity/index.js.map +1 -1
  90. package/dist/types/codeSecurity/index.test.d.ts +2 -0
  91. package/dist/types/codeSecurity/index.test.d.ts.map +1 -0
  92. package/dist/types/codeSecurity/index.test.js +19 -0
  93. package/dist/types/codeSecurity/index.test.js.map +1 -0
  94. package/dist/types/datasource/auth.d.ts +12 -1
  95. package/dist/types/datasource/auth.d.ts.map +1 -1
  96. package/dist/types/datasource/auth.js +20 -2
  97. package/dist/types/datasource/auth.js.map +1 -1
  98. package/dist/types/datasource/auth.test.js +3 -1
  99. package/dist/types/datasource/auth.test.js.map +1 -1
  100. package/dist/types/group/index.d.ts +1 -3
  101. package/dist/types/group/index.d.ts.map +1 -1
  102. package/dist/types/group/index.js +1 -3
  103. package/dist/types/group/index.js.map +1 -1
  104. package/dist/types/integration/index.d.ts +11 -1
  105. package/dist/types/integration/index.d.ts.map +1 -1
  106. package/dist/types/integration/index.js +11 -1
  107. package/dist/types/integration/index.js.map +1 -1
  108. package/dist/types/plugin/form.d.ts +32 -0
  109. package/dist/types/plugin/form.d.ts.map +1 -1
  110. package/dist/types/plugin/form.js +73 -1
  111. package/dist/types/plugin/form.js.map +1 -1
  112. package/dist/types/plugin/form.test.js +128 -0
  113. package/dist/types/plugin/form.test.js.map +1 -1
  114. package/dist/types/profile/index.d.ts +16 -1
  115. package/dist/types/profile/index.d.ts.map +1 -1
  116. package/dist/types/profile/index.js +7 -1
  117. package/dist/types/profile/index.js.map +1 -1
  118. package/dist/types/rbac/index.d.ts +2 -0
  119. package/dist/types/rbac/index.d.ts.map +1 -1
  120. package/dist/types/rbac/index.js +2 -0
  121. package/dist/types/rbac/index.js.map +1 -1
  122. package/dist/types/response/GetRecentApplicationsResponseBody.d.ts +4 -0
  123. package/dist/types/response/GetRecentApplicationsResponseBody.d.ts.map +1 -0
  124. package/dist/types/response/GetRecentApplicationsResponseBody.js +3 -0
  125. package/dist/types/response/GetRecentApplicationsResponseBody.js.map +1 -0
  126. package/dist/types/response/index.d.ts +1 -0
  127. package/dist/types/response/index.d.ts.map +1 -1
  128. package/dist/types/response/index.js +1 -0
  129. package/dist/types/response/index.js.map +1 -1
  130. package/dist-esm/database-lifecycle/appDatabaseCompatibility.d.ts +49 -0
  131. package/dist-esm/database-lifecycle/appDatabaseCompatibility.d.ts.map +1 -0
  132. package/dist-esm/database-lifecycle/appDatabaseCompatibility.js +28 -0
  133. package/dist-esm/database-lifecycle/appDatabaseCompatibility.js.map +1 -0
  134. package/dist-esm/database-lifecycle/appDatabaseCompatibility.test.d.ts +2 -0
  135. package/dist-esm/database-lifecycle/appDatabaseCompatibility.test.d.ts.map +1 -0
  136. package/dist-esm/database-lifecycle/appDatabaseCompatibility.test.js +45 -0
  137. package/dist-esm/database-lifecycle/appDatabaseCompatibility.test.js.map +1 -0
  138. package/dist-esm/database-lifecycle/destructiveMigrations.d.ts +12 -0
  139. package/dist-esm/database-lifecycle/destructiveMigrations.d.ts.map +1 -1
  140. package/dist-esm/database-lifecycle/destructiveMigrations.js +113 -0
  141. package/dist-esm/database-lifecycle/destructiveMigrations.js.map +1 -1
  142. package/dist-esm/database-lifecycle/destructiveMigrations.test.js +80 -1
  143. package/dist-esm/database-lifecycle/destructiveMigrations.test.js.map +1 -1
  144. package/dist-esm/database-lifecycle/index.d.ts +46 -2
  145. package/dist-esm/database-lifecycle/index.d.ts.map +1 -1
  146. package/dist-esm/database-lifecycle/index.js +123 -1
  147. package/dist-esm/database-lifecycle/index.js.map +1 -1
  148. package/dist-esm/database-lifecycle/providerDiagnostics.test.d.ts +2 -0
  149. package/dist-esm/database-lifecycle/providerDiagnostics.test.d.ts.map +1 -0
  150. package/dist-esm/database-lifecycle/providerDiagnostics.test.js +117 -0
  151. package/dist-esm/database-lifecycle/providerDiagnostics.test.js.map +1 -0
  152. package/dist-esm/environment-compatibility/appEnvironmentCompatibility.d.ts +65 -0
  153. package/dist-esm/environment-compatibility/appEnvironmentCompatibility.d.ts.map +1 -0
  154. package/dist-esm/environment-compatibility/appEnvironmentCompatibility.js +79 -0
  155. package/dist-esm/environment-compatibility/appEnvironmentCompatibility.js.map +1 -0
  156. package/dist-esm/environment-compatibility/appEnvironmentCompatibility.test.d.ts +2 -0
  157. package/dist-esm/environment-compatibility/appEnvironmentCompatibility.test.d.ts.map +1 -0
  158. package/dist-esm/environment-compatibility/appEnvironmentCompatibility.test.js +283 -0
  159. package/dist-esm/environment-compatibility/appEnvironmentCompatibility.test.js.map +1 -0
  160. package/dist-esm/errors/http.d.ts +15 -4
  161. package/dist-esm/errors/http.d.ts.map +1 -1
  162. package/dist-esm/errors/http.js +22 -6
  163. package/dist-esm/errors/http.js.map +1 -1
  164. package/dist-esm/errors/http.test.d.ts +2 -0
  165. package/dist-esm/errors/http.test.d.ts.map +1 -0
  166. package/dist-esm/errors/http.test.js +26 -0
  167. package/dist-esm/errors/http.test.js.map +1 -0
  168. package/dist-esm/index.d.ts +2 -0
  169. package/dist-esm/index.d.ts.map +1 -1
  170. package/dist-esm/index.js +2 -0
  171. package/dist-esm/index.js.map +1 -1
  172. package/dist-esm/plugins/templates/restApiIntegration.d.ts.map +1 -1
  173. package/dist-esm/plugins/templates/restApiIntegration.js +2 -0
  174. package/dist-esm/plugins/templates/restApiIntegration.js.map +1 -1
  175. package/dist-esm/plugins/templates/shared/auth.d.ts +3 -1
  176. package/dist-esm/plugins/templates/shared/auth.d.ts.map +1 -1
  177. package/dist-esm/plugins/templates/shared/auth.js +129 -17
  178. package/dist-esm/plugins/templates/shared/auth.js.map +1 -1
  179. package/dist-esm/plugins/templates/shared/auth.test.d.ts +2 -0
  180. package/dist-esm/plugins/templates/shared/auth.test.d.ts.map +1 -0
  181. package/dist-esm/plugins/templates/shared/auth.test.js +114 -0
  182. package/dist-esm/plugins/templates/shared/auth.test.js.map +1 -0
  183. package/dist-esm/plugins/templates/shared/restapiintegration.d.ts +2 -0
  184. package/dist-esm/plugins/templates/shared/restapiintegration.d.ts.map +1 -1
  185. package/dist-esm/plugins/templates/shared/restapiintegration.js +3 -1
  186. package/dist-esm/plugins/templates/shared/restapiintegration.js.map +1 -1
  187. package/dist-esm/socket/dist-entry.test.d.ts +2 -0
  188. package/dist-esm/socket/dist-entry.test.d.ts.map +1 -0
  189. package/dist-esm/socket/dist-entry.test.js +29 -0
  190. package/dist-esm/socket/dist-entry.test.js.map +1 -0
  191. package/dist-esm/socket/heartbeat.d.ts +258 -0
  192. package/dist-esm/socket/heartbeat.d.ts.map +1 -0
  193. package/dist-esm/socket/heartbeat.js +303 -0
  194. package/dist-esm/socket/heartbeat.js.map +1 -0
  195. package/dist-esm/socket/heartbeat.test.d.ts +2 -0
  196. package/dist-esm/socket/heartbeat.test.d.ts.map +1 -0
  197. package/dist-esm/socket/heartbeat.test.js +429 -0
  198. package/dist-esm/socket/heartbeat.test.js.map +1 -0
  199. package/dist-esm/socket/index.d.ts +1 -0
  200. package/dist-esm/socket/index.d.ts.map +1 -1
  201. package/dist-esm/socket/index.js +1 -0
  202. package/dist-esm/socket/index.js.map +1 -1
  203. package/dist-esm/socket/util.d.ts.map +1 -1
  204. package/dist-esm/socket/util.js +15 -1
  205. package/dist-esm/socket/util.js.map +1 -1
  206. package/dist-esm/socket/util.test.js +12 -2
  207. package/dist-esm/socket/util.test.js.map +1 -1
  208. package/dist-esm/types/ai/index.d.ts +3 -0
  209. package/dist-esm/types/ai/index.d.ts.map +1 -1
  210. package/dist-esm/types/ai/index.js +3 -0
  211. package/dist-esm/types/ai/index.js.map +1 -1
  212. package/dist-esm/types/application/index.d.ts +12 -0
  213. package/dist-esm/types/application/index.d.ts.map +1 -1
  214. package/dist-esm/types/application/index.js.map +1 -1
  215. package/dist-esm/types/codeSecurity/index.d.ts +55 -1
  216. package/dist-esm/types/codeSecurity/index.d.ts.map +1 -1
  217. package/dist-esm/types/codeSecurity/index.js +17 -1
  218. package/dist-esm/types/codeSecurity/index.js.map +1 -1
  219. package/dist-esm/types/codeSecurity/index.test.d.ts +2 -0
  220. package/dist-esm/types/codeSecurity/index.test.d.ts.map +1 -0
  221. package/dist-esm/types/codeSecurity/index.test.js +17 -0
  222. package/dist-esm/types/codeSecurity/index.test.js.map +1 -0
  223. package/dist-esm/types/datasource/auth.d.ts +12 -1
  224. package/dist-esm/types/datasource/auth.d.ts.map +1 -1
  225. package/dist-esm/types/datasource/auth.js +19 -2
  226. package/dist-esm/types/datasource/auth.js.map +1 -1
  227. package/dist-esm/types/datasource/auth.test.js +3 -1
  228. package/dist-esm/types/datasource/auth.test.js.map +1 -1
  229. package/dist-esm/types/group/index.d.ts +1 -3
  230. package/dist-esm/types/group/index.d.ts.map +1 -1
  231. package/dist-esm/types/group/index.js +1 -3
  232. package/dist-esm/types/group/index.js.map +1 -1
  233. package/dist-esm/types/integration/index.d.ts +11 -1
  234. package/dist-esm/types/integration/index.d.ts.map +1 -1
  235. package/dist-esm/types/integration/index.js +11 -1
  236. package/dist-esm/types/integration/index.js.map +1 -1
  237. package/dist-esm/types/plugin/form.d.ts +32 -0
  238. package/dist-esm/types/plugin/form.d.ts.map +1 -1
  239. package/dist-esm/types/plugin/form.js +68 -0
  240. package/dist-esm/types/plugin/form.js.map +1 -1
  241. package/dist-esm/types/plugin/form.test.js +129 -1
  242. package/dist-esm/types/plugin/form.test.js.map +1 -1
  243. package/dist-esm/types/profile/index.d.ts +16 -1
  244. package/dist-esm/types/profile/index.d.ts.map +1 -1
  245. package/dist-esm/types/profile/index.js +7 -1
  246. package/dist-esm/types/profile/index.js.map +1 -1
  247. package/dist-esm/types/rbac/index.d.ts +2 -0
  248. package/dist-esm/types/rbac/index.d.ts.map +1 -1
  249. package/dist-esm/types/rbac/index.js +2 -0
  250. package/dist-esm/types/rbac/index.js.map +1 -1
  251. package/dist-esm/types/response/GetRecentApplicationsResponseBody.d.ts +4 -0
  252. package/dist-esm/types/response/GetRecentApplicationsResponseBody.d.ts.map +1 -0
  253. package/dist-esm/types/response/GetRecentApplicationsResponseBody.js +2 -0
  254. package/dist-esm/types/response/GetRecentApplicationsResponseBody.js.map +1 -0
  255. package/dist-esm/types/response/index.d.ts +1 -0
  256. package/dist-esm/types/response/index.d.ts.map +1 -1
  257. package/dist-esm/types/response/index.js +1 -0
  258. package/dist-esm/types/response/index.js.map +1 -1
  259. package/package.json +1 -1
  260. package/src/database-lifecycle/appDatabaseCompatibility.test.ts +55 -0
  261. package/src/database-lifecycle/appDatabaseCompatibility.ts +62 -0
  262. package/src/database-lifecycle/destructiveMigrations.test.ts +108 -1
  263. package/src/database-lifecycle/destructiveMigrations.ts +129 -0
  264. package/src/database-lifecycle/index.ts +173 -0
  265. package/src/database-lifecycle/providerDiagnostics.test.ts +134 -0
  266. package/src/environment-compatibility/appEnvironmentCompatibility.test.ts +326 -0
  267. package/src/environment-compatibility/appEnvironmentCompatibility.ts +173 -0
  268. package/src/errors/http.test.ts +32 -0
  269. package/src/errors/http.ts +24 -6
  270. package/src/index.ts +2 -0
  271. package/src/plugins/templates/restApiIntegration.ts +2 -0
  272. package/src/plugins/templates/shared/auth.test.ts +162 -0
  273. package/src/plugins/templates/shared/auth.ts +135 -18
  274. package/src/plugins/templates/shared/restapiintegration.ts +3 -1
  275. package/src/socket/dist-entry.test.ts +29 -0
  276. package/src/socket/heartbeat.test.ts +538 -0
  277. package/src/socket/heartbeat.ts +469 -0
  278. package/src/socket/index.ts +1 -0
  279. package/src/socket/util.test.ts +14 -2
  280. package/src/socket/util.ts +15 -1
  281. package/src/types/ai/index.ts +3 -0
  282. package/src/types/application/index.ts +14 -0
  283. package/src/types/codeSecurity/index.test.ts +21 -0
  284. package/src/types/codeSecurity/index.ts +83 -1
  285. package/src/types/datasource/auth.test.ts +3 -1
  286. package/src/types/datasource/auth.ts +24 -3
  287. package/src/types/group/index.ts +1 -3
  288. package/src/types/integration/index.ts +14 -0
  289. package/src/types/plugin/form.test.ts +174 -1
  290. package/src/types/plugin/form.ts +100 -2
  291. package/src/types/profile/index.ts +19 -1
  292. package/src/types/rbac/index.ts +2 -0
  293. package/src/types/response/GetRecentApplicationsResponseBody.ts +4 -0
  294. package/src/types/response/index.ts +1 -0
@@ -0,0 +1,538 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { SocketHeartbeat } from './heartbeat.js';
4
+
5
+ const INTERVAL_MS = 100;
6
+ const TIMEOUT_MS = 50;
7
+
8
+ /** A beat whose answer the test decides, so the deadline is the only thing racing it. */
9
+ function controllableBeat() {
10
+ const pending: { reject: (error: unknown) => void; resolve: () => void }[] = [];
11
+ const beat = vi.fn(
12
+ () =>
13
+ new Promise<void>((resolve, reject) => {
14
+ pending.push({ reject, resolve });
15
+ })
16
+ );
17
+ return {
18
+ answerLast: () => pending[pending.length - 1]?.resolve(),
19
+ beat,
20
+ refuseLast: (error: unknown) => pending[pending.length - 1]?.reject(error),
21
+ pending
22
+ };
23
+ }
24
+
25
+ function heartbeat(overrides: Partial<ConstructorParameters<typeof SocketHeartbeat>[0]> = {}) {
26
+ const onLost = vi.fn();
27
+ const onOutcome = vi.fn();
28
+ const { answerLast, beat, refuseLast } = controllableBeat();
29
+ const subject = new SocketHeartbeat({
30
+ beat,
31
+ intervalMs: INTERVAL_MS,
32
+ onLost,
33
+ onOutcome,
34
+ timeoutMs: TIMEOUT_MS,
35
+ ...overrides
36
+ });
37
+ return { answerLast, beat, onLost, onOutcome, refuseLast, subject };
38
+ }
39
+
40
+ /** Runs one full interval, then the beat deadline, letting each settle. */
41
+ async function elapse(ms: number) {
42
+ await vi.advanceTimersByTimeAsync(ms);
43
+ }
44
+
45
+ const outcomes = (onOutcome: ReturnType<typeof vi.fn>): string[] => onOutcome.mock.calls.map((call) => call[0] as string);
46
+
47
+ describe('SocketHeartbeat', () => {
48
+ beforeEach(() => {
49
+ vi.useFakeTimers();
50
+ });
51
+
52
+ afterEach(() => {
53
+ vi.clearAllTimers();
54
+ vi.useRealTimers();
55
+ });
56
+
57
+ // The answer has to pace the beats, not the interval. Otherwise a channel that
58
+ // stopped answering collects a new pending request every interval, none of
59
+ // which can ever be reconciled.
60
+ it('keeps a single beat in flight however many intervals pass', async () => {
61
+ const { beat, subject } = heartbeat({ timeoutMs: 10_000 });
62
+ subject.start();
63
+
64
+ await elapse(INTERVAL_MS * 5);
65
+
66
+ expect(beat).toHaveBeenCalledTimes(1);
67
+ subject.stop();
68
+ });
69
+
70
+ // A request channel's own budget is the whole caller-facing operation, so a
71
+ // beat that borrows it cannot report a miss inside any window a caller waits in.
72
+ it('holds a beat to its own deadline rather than the request channel budget', async () => {
73
+ const { onOutcome, subject } = heartbeat();
74
+ subject.start();
75
+
76
+ await elapse(INTERVAL_MS + TIMEOUT_MS);
77
+
78
+ expect(outcomes(onOutcome)).toEqual(['grace']);
79
+ subject.stop();
80
+ });
81
+
82
+ // A refusal is a definite answer about the peer - it arrived, so the channel
83
+ // works - and counting it as a lapse would hide the very thing it reports.
84
+ it('records a refusal apart from a silence rather than excusing it as a lapse', async () => {
85
+ const { onOutcome, refuseLast, subject } = heartbeat();
86
+ subject.start();
87
+ subject.recordReceive();
88
+
89
+ await elapse(INTERVAL_MS);
90
+ refuseLast(new Error('peer is shutting down'));
91
+ await elapse(1);
92
+
93
+ expect(outcomes(onOutcome)).toEqual(['refused']);
94
+ expect(subject.account).toMatchObject({
95
+ failures: 0,
96
+ lapses: 0,
97
+ lastError: 'peer is shutting down',
98
+ refusals: 1,
99
+ unanswered: 0
100
+ });
101
+ subject.stop();
102
+ });
103
+
104
+ // A refusal is an answer: the peer is reachable and the request channel works,
105
+ // so whatever is wrong is not the connection. Spending the grace meant for a
106
+ // starved pong on one would tear a working socket down over an
107
+ // application-level problem.
108
+ it('does not let a refusal spend the grace meant for a silence', async () => {
109
+ const { onLost, onOutcome, refuseLast, subject } = heartbeat();
110
+ subject.start();
111
+
112
+ await elapse(INTERVAL_MS);
113
+ refuseLast(new Error('peer is shutting down'));
114
+ await elapse(1);
115
+
116
+ // One unanswered beat after the refusal is still only the first silence.
117
+ await elapse(INTERVAL_MS + TIMEOUT_MS);
118
+
119
+ expect(outcomes(onOutcome)).toEqual(['refused', 'grace']);
120
+ expect(onLost).not.toHaveBeenCalled();
121
+ subject.stop();
122
+ });
123
+
124
+ // ...but an answer to one beat says nothing about the beats that went
125
+ // missing, so a refusal must not clear a run of silences either. Otherwise a
126
+ // channel alternating between timing out and refusing never gives up.
127
+ it('does not let a refusal clear a run of silences', async () => {
128
+ const { onLost, onOutcome, refuseLast, subject } = heartbeat();
129
+ subject.start();
130
+
131
+ await elapse(INTERVAL_MS + TIMEOUT_MS);
132
+ await elapse(INTERVAL_MS - TIMEOUT_MS);
133
+ refuseLast(new Error('peer is shutting down'));
134
+ await elapse(1);
135
+ await elapse(INTERVAL_MS + TIMEOUT_MS);
136
+
137
+ expect(outcomes(onOutcome)).toEqual(['grace', 'refused', 'miss']);
138
+ expect(onLost).toHaveBeenCalledTimes(1);
139
+ subject.stop();
140
+ });
141
+
142
+ // Inbound traffic proves the connection is alive, so an unanswered beat on a
143
+ // socket that is still delivering data indicts the request channel, not the
144
+ // connection. Closing on that would kill a healthy-but-busy socket.
145
+ it('lapses an unanswered beat while the socket is provably receiving', async () => {
146
+ const { onLost, onOutcome, subject } = heartbeat();
147
+ subject.start();
148
+
149
+ await elapse(INTERVAL_MS);
150
+ subject.recordReceive();
151
+ await elapse(TIMEOUT_MS);
152
+
153
+ expect(outcomes(onOutcome)).toEqual(['lapse']);
154
+ expect(subject.account).toMatchObject({ failures: 0, lapses: 1, unanswered: 1 });
155
+ expect(onLost).not.toHaveBeenCalled();
156
+ subject.stop();
157
+ });
158
+
159
+ // Proof of life has to undo a suspicion the same way an answer does, or a
160
+ // single miss during a large transfer stays on the books until a pong lands.
161
+ it('resets the miss count when inbound traffic proves the connection alive', async () => {
162
+ const { onLost, subject } = heartbeat();
163
+ subject.start();
164
+
165
+ // First beat: nothing inbound, so a genuine miss held back by grace.
166
+ await elapse(INTERVAL_MS + TIMEOUT_MS);
167
+ expect(subject.account).toMatchObject({ failures: 1 });
168
+
169
+ // Second beat: unanswered, but the socket is provably carrying traffic.
170
+ await elapse(INTERVAL_MS - TIMEOUT_MS);
171
+ subject.recordReceive();
172
+ await elapse(TIMEOUT_MS);
173
+
174
+ expect(subject.account).toMatchObject({ failures: 0, lapses: 1, unanswered: 2 });
175
+ expect(onLost).not.toHaveBeenCalled();
176
+ subject.stop();
177
+ });
178
+
179
+ // Inbound data proves the receive path, not that the request channel answers.
180
+ // Without a bound, a wedged channel hides behind continuous pushes forever.
181
+ it('stops excusing a silent request channel once the silences are exhausted', async () => {
182
+ const maxConsecutiveUnanswered = 3;
183
+ const { onLost, onOutcome, subject } = heartbeat({ maxConsecutiveUnanswered });
184
+ subject.start();
185
+
186
+ // Traffic arrives during every beat's own wait, so each one is excused -
187
+ // until the allowance runs out and the next silence is believed anyway.
188
+ for (let beats = 0; beats < maxConsecutiveUnanswered; beats += 1) {
189
+ await elapse(INTERVAL_MS - (beats === 0 ? 0 : TIMEOUT_MS));
190
+ subject.recordReceive();
191
+ await elapse(TIMEOUT_MS);
192
+ }
193
+
194
+ expect(outcomes(onOutcome)).toEqual(['lapse', 'lapse', 'miss']);
195
+ expect(onLost).toHaveBeenCalledTimes(1);
196
+ // Both counts ride along on the close: how long the silence had run, and
197
+ // how much of it the socket was demonstrably carrying traffic through.
198
+ expect(onLost.mock.calls[0]?.[0]).toMatchObject({
199
+ failures: 1,
200
+ lapses: maxConsecutiveUnanswered - 1,
201
+ unanswered: maxConsecutiveUnanswered
202
+ });
203
+ subject.stop();
204
+ });
205
+
206
+ // The bound is on silence, not on the excuse. Two unanswered beats with
207
+ // nothing inbound must report the same run length as two that were excused,
208
+ // or telemetry cannot tell "died after two misses" from "wedged behind
209
+ // continuous pushes until the cap".
210
+ it('counts every unanswered beat toward the bound, excused or not', async () => {
211
+ const { onLost, subject } = heartbeat();
212
+ subject.start();
213
+
214
+ await elapse(INTERVAL_MS * 2 + TIMEOUT_MS);
215
+
216
+ expect(onLost).toHaveBeenCalledTimes(1);
217
+ expect(onLost.mock.calls[0]?.[0]).toMatchObject({ failures: 2, lapses: 0, unanswered: 2 });
218
+ subject.stop();
219
+ });
220
+
221
+ // The bound is what stops a wedged request channel hiding behind inbound
222
+ // pushes forever, and a refusal answers one beat - not the beats that already
223
+ // went missing. Clearing the run on a refusal hands that channel an unlimited
224
+ // deferral: every silence is excused by traffic, so `unanswered` is the only
225
+ // thing left that can ever end it.
226
+ it('does not let a refusal defer the bound on a silent request channel', async () => {
227
+ const maxConsecutiveUnanswered = 3;
228
+ const { onLost, onOutcome, refuseLast, subject } = heartbeat({ maxConsecutiveUnanswered });
229
+ subject.start();
230
+
231
+ // Traffic arrives during the beat's own wait, so this silence is excused.
232
+ await elapse(INTERVAL_MS);
233
+ subject.recordReceive();
234
+ await elapse(TIMEOUT_MS);
235
+
236
+ // A refusal interleaves: recorded as the answer it is, but it must not wipe
237
+ // the run of silence already on the books.
238
+ await elapse(INTERVAL_MS - TIMEOUT_MS);
239
+ refuseLast(new Error('peer is shutting down'));
240
+ await elapse(1);
241
+
242
+ await elapse(INTERVAL_MS - 1);
243
+ subject.recordReceive();
244
+ await elapse(TIMEOUT_MS);
245
+
246
+ await elapse(INTERVAL_MS - TIMEOUT_MS);
247
+ subject.recordReceive();
248
+ await elapse(TIMEOUT_MS);
249
+
250
+ expect(outcomes(onOutcome)).toEqual(['lapse', 'refused', 'lapse', 'miss']);
251
+ expect(onLost).toHaveBeenCalledTimes(1);
252
+ expect(onLost.mock.calls[0]?.[0]).toMatchObject({ refusals: 1, unanswered: maxConsecutiveUnanswered });
253
+ subject.stop();
254
+ });
255
+
256
+ // `unanswered` is what the caller reports as the length of the silence that
257
+ // killed the connection, and it is documented as bounded by the cap. Beats
258
+ // keep going out after the alarm has been raised - the caller still has to
259
+ // tear the socket down - so without saturating it the reported run climbs
260
+ // past the cap it is promised to respect.
261
+ it('never reports a longer silence than the bound allows', async () => {
262
+ const maxConsecutiveUnanswered = 3;
263
+ const { onOutcome, subject } = heartbeat({ maxConsecutiveUnanswered });
264
+ subject.start();
265
+
266
+ await elapse(INTERVAL_MS * 5 + TIMEOUT_MS);
267
+
268
+ expect(subject.account.unanswered).toBe(maxConsecutiveUnanswered);
269
+ const lastAccount = onOutcome.mock.calls[onOutcome.mock.calls.length - 1]?.[2] as { unanswered: number };
270
+ expect(lastAccount.unanswered).toBe(maxConsecutiveUnanswered);
271
+ subject.stop();
272
+ });
273
+
274
+ // The alarm is edge-triggered so one run of misses causes one teardown, but
275
+ // that assumes the teardown happened. When the caller cannot act - the socket
276
+ // lookup throws, the close rejects - latching would mean nothing ever closes
277
+ // the connection and nothing ever says so again.
278
+ it('keeps the alarm armed when the caller could not act on the loss', async () => {
279
+ const onLost = vi.fn(() => {
280
+ throw new Error('the socket was already gone');
281
+ });
282
+ const { subject } = heartbeat({ maxConsecutiveMisses: 1, onLost });
283
+ subject.start();
284
+
285
+ await elapse(INTERVAL_MS + TIMEOUT_MS);
286
+ expect(onLost).toHaveBeenCalledTimes(1);
287
+
288
+ await elapse(INTERVAL_MS);
289
+ expect(onLost).toHaveBeenCalledTimes(2);
290
+ subject.stop();
291
+ });
292
+
293
+ // One missed beat is survivable: a bulk response is a single websocket message
294
+ // and no receive event fires until its last byte lands, so a transfer only has
295
+ // to finish inside one extra interval for the next answer to get through.
296
+ it('gives the first genuine miss grace and loses the beat on the second', async () => {
297
+ const { onLost, onOutcome, subject } = heartbeat();
298
+ subject.start();
299
+
300
+ await elapse(INTERVAL_MS + TIMEOUT_MS);
301
+ expect(onLost).not.toHaveBeenCalled();
302
+
303
+ await elapse(INTERVAL_MS);
304
+
305
+ expect(outcomes(onOutcome)).toEqual(['grace', 'miss']);
306
+ expect(onLost).toHaveBeenCalledTimes(1);
307
+ subject.stop();
308
+ });
309
+
310
+ it('resets both counters when a beat is answered', async () => {
311
+ const { answerLast, subject } = heartbeat();
312
+ subject.start();
313
+
314
+ await elapse(INTERVAL_MS + TIMEOUT_MS);
315
+ expect(subject.account).toMatchObject({ failures: 1 });
316
+
317
+ await elapse(INTERVAL_MS - TIMEOUT_MS);
318
+ answerLast();
319
+ await elapse(1);
320
+
321
+ expect(subject.account).toMatchObject({ failures: 0, lapses: 0, unanswered: 0 });
322
+ expect(subject.account.lastSuccessAt).toBeDefined();
323
+ subject.stop();
324
+ });
325
+
326
+ // A deadline armed for a previous connection fires after the restart. Letting
327
+ // it tick the new run's counters would close a healthy socket on the strength
328
+ // of a dead one's silence.
329
+ it('ignores a beat armed before the heartbeat was restarted', async () => {
330
+ const { onLost, onOutcome, subject } = heartbeat();
331
+ subject.start();
332
+
333
+ await elapse(INTERVAL_MS);
334
+ subject.stop();
335
+ subject.start();
336
+ await elapse(TIMEOUT_MS);
337
+
338
+ expect(onLost).not.toHaveBeenCalled();
339
+ expect(outcomes(onOutcome)).toEqual([]);
340
+ expect(subject.account).toMatchObject({ failures: 0, lapses: 0 });
341
+ subject.stop();
342
+ });
343
+
344
+ // A socket can die before any beat settles, and unanswered beats are the
345
+ // strongest evidence there is that the peer was gone - so the account cannot
346
+ // be built from settled outcomes alone.
347
+ it('counts every beat issued, including one still waiting', async () => {
348
+ const { subject } = heartbeat({ timeoutMs: 10_000 });
349
+ subject.start();
350
+
351
+ await elapse(INTERVAL_MS);
352
+
353
+ expect(subject.account).toMatchObject({ failures: 0, sent: 1 });
354
+ subject.stop();
355
+ });
356
+
357
+ // `sent` says a beat went out; it cannot say whether the last one came back.
358
+ // The caller reporting a death needs both, because a socket that answered a
359
+ // beat and then went quiet is otherwise indistinguishable from one answering
360
+ // right up to the close.
361
+ it('stamps the outstanding beat and clears the stamp when it settles', async () => {
362
+ const { answerLast, subject } = heartbeat({ timeoutMs: 10_000 });
363
+ subject.start();
364
+
365
+ await elapse(INTERVAL_MS);
366
+ const { pendingSince } = subject.account;
367
+ expect(pendingSince).toBeTypeOf('number');
368
+
369
+ answerLast();
370
+ await elapse(0);
371
+
372
+ expect(subject.account.pendingSince).toBeUndefined();
373
+ subject.stop();
374
+ });
375
+
376
+ // The account is read after the socket is gone, and stopping is the first
377
+ // thing a caller does with a dead connection. A stamp cleared on the way out
378
+ // would erase the one fact that names what the beat was doing when it died -
379
+ // and no settle is coming, because a retired beat's rejection is dropped.
380
+ it('keeps the outstanding beat stamped through a stop', async () => {
381
+ const { subject } = heartbeat({ timeoutMs: 10_000 });
382
+ subject.start();
383
+
384
+ await elapse(INTERVAL_MS);
385
+ subject.stop();
386
+
387
+ expect(subject.account.pendingSince).toBeTypeOf('number');
388
+ });
389
+
390
+ // A channel that cannot carry a beat yet has no turn to protect either, so a
391
+ // skipped beat is not a miss - and must not be booked as an answer, which is
392
+ // what returning early from the beat itself would amount to.
393
+ it('skips a beat while the channel is not ready to carry one', async () => {
394
+ const { beat, onOutcome, subject } = heartbeat({ shouldBeat: () => false, timeoutMs: 10_000 });
395
+ subject.start();
396
+
397
+ await elapse(INTERVAL_MS * 3);
398
+
399
+ expect(beat).not.toHaveBeenCalled();
400
+ expect(outcomes(onOutcome)).toEqual([]);
401
+ expect(subject.account).toMatchObject({ failures: 0, sent: 0 });
402
+ subject.stop();
403
+ });
404
+
405
+ // The alarm is edge-triggered: the counters are an account of what happened,
406
+ // read when a socket dies, and resetting them to re-arm would erase the very
407
+ // evidence the death report is built from.
408
+ it('raises the alarm once rather than on every beat after it', async () => {
409
+ const { onLost, onOutcome, subject } = heartbeat();
410
+ subject.start();
411
+
412
+ await elapse(INTERVAL_MS * 4 + TIMEOUT_MS);
413
+
414
+ expect(outcomes(onOutcome)).toEqual(['grace', 'miss', 'miss', 'miss']);
415
+ expect(onLost).toHaveBeenCalledTimes(1);
416
+ expect(subject.account).toMatchObject({ failures: 4 });
417
+ subject.stop();
418
+ });
419
+
420
+ it('re-arms the alarm once the peer answers again', async () => {
421
+ const { answerLast, onLost, subject } = heartbeat();
422
+ subject.start();
423
+
424
+ await elapse(INTERVAL_MS * 2 + TIMEOUT_MS);
425
+ expect(onLost).toHaveBeenCalledTimes(1);
426
+
427
+ await elapse(INTERVAL_MS - TIMEOUT_MS);
428
+ answerLast();
429
+ await elapse(1);
430
+ expect(subject.account).toMatchObject({ failures: 0 });
431
+
432
+ await elapse(INTERVAL_MS * 2 + TIMEOUT_MS);
433
+
434
+ expect(onLost).toHaveBeenCalledTimes(2);
435
+ subject.stop();
436
+ });
437
+
438
+ // Two callers drive the editor's beat: its own cadence, and the moment a
439
+ // user comes back to the keyboard. The second must not have to wait out an
440
+ // interval sized for idle connections.
441
+ it('beats on demand outside the interval', async () => {
442
+ const { beat, onOutcome, subject } = heartbeat({ timeoutMs: 10_000 });
443
+
444
+ // Not awaited: the beat is deliberately left in flight, which is what an
445
+ // on-demand caller sees when the peer is slow.
446
+ void subject.beatNow();
447
+ await elapse(1);
448
+
449
+ expect(beat).toHaveBeenCalledTimes(1);
450
+ expect(subject.account).toMatchObject({ sent: 1 });
451
+ expect(outcomes(onOutcome)).toEqual([]);
452
+ });
453
+
454
+ it('lets an on-demand beat and the interval share the in-flight guard', async () => {
455
+ const { beat, subject } = heartbeat({ timeoutMs: 10_000 });
456
+ subject.start();
457
+
458
+ await elapse(INTERVAL_MS);
459
+ await subject.beatNow();
460
+
461
+ expect(beat).toHaveBeenCalledTimes(1);
462
+ subject.stop();
463
+ });
464
+
465
+ // A caller can learn it has a new connection before it is ready to beat on
466
+ // one. The old socket's armed deadline has to stop counting at that moment,
467
+ // not whenever the first new beat happens to go out.
468
+ it('retires an armed beat on reset without touching the interval', async () => {
469
+ const { onLost, onOutcome, subject } = heartbeat();
470
+ subject.start();
471
+
472
+ await elapse(INTERVAL_MS);
473
+ subject.reset();
474
+ await elapse(TIMEOUT_MS);
475
+
476
+ expect(outcomes(onOutcome)).toEqual([]);
477
+ expect(onLost).not.toHaveBeenCalled();
478
+ expect(subject.account).toMatchObject({ failures: 0, sent: 0, unanswered: 0 });
479
+
480
+ // Still beating: reset is not a stop.
481
+ await elapse(INTERVAL_MS);
482
+ expect(subject.account).toMatchObject({ sent: 1 });
483
+ subject.stop();
484
+ });
485
+
486
+ // One heartbeat outlives the connections it beats for, and a superseded
487
+ // connection's teardown arrives late. If its stop were not bound to the
488
+ // cadence it started, it would take the live connection's beat down with it -
489
+ // silently, since nothing re-arms an interval that was never meant to stop.
490
+ it('ignores a stop belonging to a cadence that has been replaced', async () => {
491
+ const { beat, subject } = heartbeat({ timeoutMs: 10_000 });
492
+
493
+ const stopFirst = subject.start();
494
+ subject.start();
495
+ stopFirst();
496
+
497
+ await elapse(INTERVAL_MS);
498
+
499
+ expect(beat).toHaveBeenCalledTimes(1);
500
+ subject.stop();
501
+ });
502
+
503
+ it('stops the cadence it started when nothing has replaced it', async () => {
504
+ const { beat, subject } = heartbeat({ timeoutMs: 10_000 });
505
+
506
+ const stop = subject.start();
507
+ stop();
508
+
509
+ await elapse(INTERVAL_MS * 2);
510
+
511
+ expect(beat).not.toHaveBeenCalled();
512
+ });
513
+
514
+ it('replaces a running cadence rather than leaving two beating', async () => {
515
+ const { beat, subject } = heartbeat({ timeoutMs: 10_000 });
516
+
517
+ subject.start();
518
+ subject.start();
519
+
520
+ await elapse(INTERVAL_MS);
521
+
522
+ expect(beat).toHaveBeenCalledTimes(1);
523
+ subject.stop();
524
+ });
525
+
526
+ it('sends nothing before it is started or after it is stopped', async () => {
527
+ const { beat, subject } = heartbeat();
528
+
529
+ await elapse(INTERVAL_MS * 2);
530
+ expect(beat).not.toHaveBeenCalled();
531
+
532
+ subject.start();
533
+ subject.stop();
534
+ await elapse(INTERVAL_MS * 2);
535
+
536
+ expect(beat).not.toHaveBeenCalled();
537
+ });
538
+ });