@fedify/fedify 2.4.0-pr.936.41 → 2.5.0-dev.2271

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 (244) hide show
  1. package/README.md +50 -39
  2. package/dist/{assert-CJ1KXcvv.mjs → assert-D1Eafmd_.mjs} +1 -1
  3. package/dist/{assert_equals-BV7oLMxm.mjs → assert_equals-Z6IcEbWU.mjs} +2 -28
  4. package/dist/assert_exists-cffSpJ7X.mjs +28 -0
  5. package/dist/assert_false-CPdRnc3E.mjs +23 -0
  6. package/dist/assert_greater_or_equal-BlafOF4-.mjs +31 -0
  7. package/dist/{assert_instance_of-Q9KTBnrf.mjs → assert_instance_of-mrXWWNY-.mjs} +1 -1
  8. package/dist/{assert_not_equals-EPJPoxuf.mjs → assert_not_equals-CpPK2t89.mjs} +2 -1
  9. package/dist/assert_rejects-CTptdYck.mjs +39 -0
  10. package/dist/{assert_strict_equals-Ec20AUwa.mjs → assert_strict_equals-CYvBmkpC.mjs} +2 -1
  11. package/dist/{assert_throws-NiFfqbnH.mjs → assert_throws-BYITOvPO.mjs} +2 -1
  12. package/dist/assertion_error-B-cx0fFA.mjs +31 -0
  13. package/dist/audience-CHQ-FlLD.mjs +95 -0
  14. package/dist/{builder-DonuxRl_.mjs → builder-Bda6LK7A.mjs} +169 -14
  15. package/dist/{circuit-breaker-CBGyz5F7.mjs → circuit-breaker-XJ53md4S.mjs} +224 -17
  16. package/dist/circuit-breaker-test-utils-DW6YC_Pe.mjs +14 -0
  17. package/dist/{client-CjF5IYQS.mjs → client-CEnNz8O7.mjs} +144 -8
  18. package/dist/{codec-BBga-FWI.mjs → codec-C6rTrpIp.mjs} +26 -3
  19. package/dist/{collection-BIhlldqK.mjs → collection-CnnP1UAv.mjs} +1 -1
  20. package/dist/compat/mod.cjs +1 -1
  21. package/dist/compat/mod.d.cts +1 -1
  22. package/dist/compat/mod.d.ts +1 -1
  23. package/dist/compat/mod.js +1 -1
  24. package/dist/compat/outgoing-jsonld.test.mjs +71 -3
  25. package/dist/compat/public-audience.test.mjs +14 -5
  26. package/dist/compat/secured-document.test.d.mts +2 -0
  27. package/dist/compat/secured-document.test.mjs +58 -0
  28. package/dist/compat/transformers.test.mjs +5 -5
  29. package/dist/compound-proof-B-9mXxFW.mjs +989 -0
  30. package/dist/{context-Zii1f5xS.d.ts → context-7NyiqIcg.d.ts} +1600 -25
  31. package/dist/{context-BJXxPElm.mjs → context-8Tu9kea0.mjs} +21 -63
  32. package/dist/{context-lxX6I7as.d.cts → context-tsYPJ2Ew.d.cts} +1600 -25
  33. package/dist/{deno-BHzKn9Ig.mjs → deno-oR5aJKpU.mjs} +1 -1
  34. package/dist/dist-DJQM4u7i.mjs +189 -0
  35. package/dist/docloader-DBoYbvYW.mjs +87 -0
  36. package/dist/{esm-yYQfPhIY.mjs → esm-BHXXx4Wx.mjs} +13 -6
  37. package/dist/federation/audience.test.d.mts +2 -0
  38. package/dist/federation/audience.test.mjs +130 -0
  39. package/dist/federation/builder.test.mjs +54 -5
  40. package/dist/federation/circuit-breaker.test.mjs +838 -7
  41. package/dist/federation/collection.test.mjs +3 -3
  42. package/dist/federation/compound-fanout.test.d.mts +2 -0
  43. package/dist/federation/compound-fanout.test.mjs +228 -0
  44. package/dist/federation/handler-compound-proof.test.d.mts +2 -0
  45. package/dist/federation/handler-compound-proof.test.mjs +384 -0
  46. package/dist/federation/handler.test.mjs +385 -15
  47. package/dist/federation/hashlink-media.test.d.mts +2 -0
  48. package/dist/federation/hashlink-media.test.mjs +422 -0
  49. package/dist/federation/idempotency.test.mjs +6 -6
  50. package/dist/federation/inbox-report.test.d.mts +2 -0
  51. package/dist/federation/inbox-report.test.mjs +475 -0
  52. package/dist/federation/inbox.test.mjs +3 -3
  53. package/dist/federation/keycache.test.mjs +215 -14
  54. package/dist/federation/kv.test.mjs +3 -3
  55. package/dist/federation/metrics.test.mjs +5 -5
  56. package/dist/federation/middleware.test.mjs +967 -35
  57. package/dist/federation/mod.cjs +18 -261
  58. package/dist/federation/mod.d.cts +5 -5
  59. package/dist/federation/mod.d.ts +5 -5
  60. package/dist/federation/mod.js +279 -1
  61. package/dist/federation/mq.test.mjs +123 -47
  62. package/dist/federation/negotiation.test.mjs +4 -4
  63. package/dist/federation/outgoing-proof.test.d.mts +2 -0
  64. package/dist/federation/outgoing-proof.test.mjs +931 -0
  65. package/dist/federation/portable-actor.test.d.mts +2 -0
  66. package/dist/federation/portable-actor.test.mjs +496 -0
  67. package/dist/federation/portable-chain.test.d.mts +2 -0
  68. package/dist/federation/portable-chain.test.mjs +370 -0
  69. package/dist/federation/portable-collection.test.d.mts +2 -0
  70. package/dist/federation/portable-collection.test.mjs +832 -0
  71. package/dist/federation/portable-delivery.test.d.mts +2 -0
  72. package/dist/federation/portable-delivery.test.mjs +746 -0
  73. package/dist/federation/portable-gateway-keys.test.d.mts +2 -0
  74. package/dist/federation/portable-gateway-keys.test.mjs +686 -0
  75. package/dist/federation/portable-inbox.test.d.mts +2 -0
  76. package/dist/federation/portable-inbox.test.mjs +1535 -0
  77. package/dist/federation/portable-parse-uri.test.d.mts +2 -0
  78. package/dist/federation/portable-parse-uri.test.mjs +348 -0
  79. package/dist/federation/portable-route-activity.test.d.mts +2 -0
  80. package/dist/federation/portable-route-activity.test.mjs +259 -0
  81. package/dist/federation/portable.test.d.mts +2 -0
  82. package/dist/federation/portable.test.mjs +1072 -0
  83. package/dist/federation/retry.test.mjs +4 -3
  84. package/dist/federation/router.test.mjs +9 -159
  85. package/dist/federation/send.test.mjs +245 -27
  86. package/dist/federation/tasks/codec.test.mjs +4 -4
  87. package/dist/federation/tasks/enqueue.test.mjs +42 -4
  88. package/dist/federation/tasks/tasks.test.mjs +7 -6
  89. package/dist/federation/temporal.test.mjs +4 -4
  90. package/dist/federation/webfinger.test.mjs +275 -12
  91. package/dist/{middleware-FrSMAYEx.cjs → federation-CkZiAcWY.cjs} +8640 -3722
  92. package/dist/http-B5JT115n.cjs +10630 -0
  93. package/dist/http-DydWCB7R.js +6527 -0
  94. package/dist/{http-CPVMUgsW.mjs → http-siciWkmx.mjs} +397 -168
  95. package/dist/key-BspPiXqn.mjs +951 -0
  96. package/dist/key-C1nv7_jD.d.ts +1125 -0
  97. package/dist/key-DYxuyd-s.d.cts +1125 -0
  98. package/dist/key-owner-evidence-APB6NpSk.mjs +108 -0
  99. package/dist/keycache-Da8A8mZa.mjs +200 -0
  100. package/dist/{kv-D6hNiMTK.d.ts → kv-B2mzK5tl.d.cts} +3 -2
  101. package/dist/{kv-gJ8LYbxX.d.cts → kv-B2mzK5tl.d.ts} +3 -2
  102. package/dist/kv-DBRWVx3Q.mjs +104 -0
  103. package/dist/{kv-cache-KyGqJqSp.mjs → kv-cache-BF1hnW65.mjs} +1 -1
  104. package/dist/{kv-cache-5bnX3-Jq.js → kv-cache-CTQr1tbn.js} +54 -16
  105. package/dist/{kv-cache-DQ29C4XL.cjs → kv-cache-DCac1nEU.cjs} +55 -19
  106. package/dist/{ld-Cj9F_5AP.mjs → ld-CzaP-hJl.mjs} +44 -12
  107. package/dist/map-local-context-conflict-DOYEEJ3u.mjs +204 -0
  108. package/dist/map-local-create-note-B0SeXJms.mjs +214 -0
  109. package/dist/{metrics-DMk-nTxI.mjs → metrics-CYg9yxBL.mjs} +3 -2
  110. package/dist/metrics-DVKYY6K1.d.cts +36 -0
  111. package/dist/metrics-DoRtI0qU.d.ts +36 -0
  112. package/dist/{middleware-Y-kpsp6S.js → middleware-CsDDXKuL.js} +5493 -1103
  113. package/dist/{middleware-CvwTUgnP.mjs → middleware-D7_LO-Vs.mjs} +3157 -272
  114. package/dist/{middleware-CEctxBN6.mjs → middleware-DWjrfpcz.mjs} +1 -1
  115. package/dist/{mod-gOlPDuT8.d.cts → mod-BojP2Bw3.d.cts} +1 -1
  116. package/dist/mod-CavhAgLw.d.cts +320 -0
  117. package/dist/mod-CtpF2BU5.d.ts +320 -0
  118. package/dist/{mod-BtEaIEcD.d.cts → mod-DEZXwV6J.d.ts} +10 -4
  119. package/dist/{mod-UMmveiO0.d.ts → mod-Dw-nPxQh.d.cts} +10 -4
  120. package/dist/{mod-DLc2Ot6f.d.ts → mod-Xj5P4s4d.d.ts} +1 -1
  121. package/dist/mod.cjs +40 -39
  122. package/dist/mod.d.cts +10 -11
  123. package/dist/mod.d.ts +10 -11
  124. package/dist/mod.js +9 -9
  125. package/dist/{mq-llHKivST.mjs → mq-Dm1Wmtm-.mjs} +6 -0
  126. package/dist/nodeinfo/client.test.mjs +139 -4
  127. package/dist/nodeinfo/handler.test.mjs +5 -5
  128. package/dist/nodeinfo/mod.cjs +1 -1
  129. package/dist/nodeinfo/mod.d.cts +1 -1
  130. package/dist/nodeinfo/mod.d.ts +1 -1
  131. package/dist/nodeinfo/mod.js +1 -1
  132. package/dist/nodeinfo/types.test.mjs +4 -4
  133. package/dist/otel/exporter.test.mjs +3 -3
  134. package/dist/otel/mod.cjs +0 -1
  135. package/dist/otel/mod.d.cts +1 -1
  136. package/dist/otel/mod.d.ts +1 -1
  137. package/dist/{outgoing-jsonld-URuqsICz.mjs → outgoing-jsonld-4IJqPYos.mjs} +12 -10
  138. package/dist/{owner-DEvZuyOE.d.cts → owner-BF0L9cMt.d.cts} +11 -0
  139. package/dist/{owner-CnngXDNJ.d.ts → owner-BSzr5-I8.d.ts} +11 -0
  140. package/dist/owner-DYgfTFHy.mjs +267 -0
  141. package/dist/portable-collection-BpsVsRov.js +670 -0
  142. package/dist/portable-collection-C74P1vtC.mjs +418 -0
  143. package/dist/portable-collection-DZd2bm7M.cjs +686 -0
  144. package/dist/portable-key-BIMLrAP4.mjs +549 -0
  145. package/dist/portable-key-id-Ca30-hOa.mjs +248 -0
  146. package/dist/proof-Dxy6ByqI.mjs +1299 -0
  147. package/dist/{public-audience-Cr5MsnMq.mjs → public-audience--klBIOk1.mjs} +15 -10
  148. package/dist/router-u5O5KCQr.mjs +156 -0
  149. package/dist/runtime/mod.cjs +1 -1
  150. package/dist/runtime/mod.js +1 -1
  151. package/dist/secured-document-CxzZZ9nL.mjs +35 -0
  152. package/dist/send-C4dDpAxm.mjs +495 -0
  153. package/dist/sig/accept.test.mjs +1 -1
  154. package/dist/sig/compound-proof-discovery.test.d.mts +2 -0
  155. package/dist/sig/compound-proof-discovery.test.mjs +331 -0
  156. package/dist/sig/compound-proof-producer.test.d.mts +2 -0
  157. package/dist/sig/compound-proof-producer.test.mjs +107 -0
  158. package/dist/sig/compound-proof-verification.test.d.mts +2 -0
  159. package/dist/sig/compound-proof-verification.test.mjs +833 -0
  160. package/dist/sig/compound-proof.test.d.mts +2 -0
  161. package/dist/sig/compound-proof.test.mjs +411 -0
  162. package/dist/sig/http.test.mjs +702 -31
  163. package/dist/sig/key-owner-evidence.test.d.mts +2 -0
  164. package/dist/sig/key-owner-evidence.test.mjs +100 -0
  165. package/dist/sig/key.test.mjs +220 -8
  166. package/dist/sig/ld.test.mjs +65 -7
  167. package/dist/sig/mitra-receive-interop.test.d.mts +2 -0
  168. package/dist/sig/mitra-receive-interop.test.mjs +260 -0
  169. package/dist/sig/mitra-send-interop.test.d.mts +2 -0
  170. package/dist/sig/mitra-send-interop.test.mjs +461 -0
  171. package/dist/sig/mod.cjs +18 -16
  172. package/dist/sig/mod.d.cts +4 -4
  173. package/dist/sig/mod.d.ts +4 -4
  174. package/dist/sig/mod.js +3 -3
  175. package/dist/sig/owner.test.mjs +108 -9
  176. package/dist/sig/portable-collection.test.d.mts +2 -0
  177. package/dist/sig/portable-collection.test.mjs +697 -0
  178. package/dist/sig/portable-interop.test.d.mts +2 -0
  179. package/dist/sig/portable-interop.test.mjs +156 -0
  180. package/dist/sig/portable-key-id.test.d.mts +2 -0
  181. package/dist/sig/portable-key-id.test.mjs +67 -0
  182. package/dist/sig/portable-key.test.d.mts +2 -0
  183. package/dist/sig/portable-key.test.mjs +1439 -0
  184. package/dist/sig/proof.test.mjs +1753 -16
  185. package/dist/sig/tootik-request-interop.test.d.mts +2 -0
  186. package/dist/sig/tootik-request-interop.test.mjs +190 -0
  187. package/dist/sig/tootik-send-interop.test.d.mts +2 -0
  188. package/dist/sig/tootik-send-interop.test.mjs +331 -0
  189. package/dist/sig/verification.test.d.mts +2 -0
  190. package/dist/sig/verification.test.mjs +310 -0
  191. package/dist/std__assert-DvMDRPes.mjs +8 -0
  192. package/dist/{testing-CO7ftO8A.mjs → tasks-IG3pHfvZ.mjs} +2 -2
  193. package/dist/{temporal-fsXO5IvD.mjs → temporal-D0KJI4NF.mjs} +3 -3
  194. package/dist/testing/context.test.d.mts +2 -0
  195. package/dist/testing/context.test.mjs +19 -0
  196. package/dist/testing/kv.test.d.mts +2 -0
  197. package/dist/testing/kv.test.mjs +101 -0
  198. package/dist/testing/mod.d.mts +1617 -48
  199. package/dist/testing/mod.mjs +6 -3
  200. package/dist/testing-t8xDeJZ5.mjs +8 -0
  201. package/dist/{transformers-DGwl-hD1.cjs → transformers-BalCrSsr.cjs} +0 -1
  202. package/dist/{types-DC1pNgJA.js → types-_S2_MeLF.js} +144 -8
  203. package/dist/{types-CPym83zc.cjs → types-poIao40f.cjs} +160 -7
  204. package/dist/utils/docloader.test.mjs +530 -8
  205. package/dist/utils/kv-cache.test.mjs +2 -2
  206. package/dist/utils/mod.cjs +1 -1
  207. package/dist/utils/mod.d.cts +1 -1
  208. package/dist/utils/mod.d.ts +1 -1
  209. package/dist/utils/mod.js +1 -1
  210. package/dist/verification-C3954hQh.mjs +5 -0
  211. package/dist/verification-Dpfcw8ml.mjs +105 -0
  212. package/dist/vocab/cjs.test.mjs +1 -1
  213. package/package.json +8 -8
  214. package/dist/assert_rejects-EgstcQy5.mjs +0 -82
  215. package/dist/docloader-e1khNUKy.mjs +0 -49
  216. package/dist/http-CsmQ-BQ4.js +0 -2483
  217. package/dist/http-VyDTd4G3.d.cts +0 -553
  218. package/dist/http-hlIjmewS.cjs +0 -2694
  219. package/dist/http-lf8Hsd91.d.ts +0 -553
  220. package/dist/key-DfPsX8hv.mjs +0 -453
  221. package/dist/keycache-_DHqz5z_.mjs +0 -97
  222. package/dist/metrics-CoUJLQhZ.d.cts +0 -276
  223. package/dist/metrics-Dc4jR-IC.d.ts +0 -276
  224. package/dist/mod-B0hW12_O.d.cts +0 -307
  225. package/dist/mod-COIAjwRS.d.ts +0 -307
  226. package/dist/owner-Br9YYzn_.mjs +0 -120
  227. package/dist/proof-B4ngoaNQ.mjs +0 -316
  228. package/dist/proof-B6E0XUXJ.js +0 -1415
  229. package/dist/proof-NN4xCQGL.cjs +0 -1562
  230. package/dist/send-CxHKUNzv.mjs +0 -239
  231. package/dist/std__assert-B2NnY9bq.mjs +0 -79
  232. package/dist/temporal-polyfill-BCdTHexd.cjs +0 -3565
  233. /package/dist/{accept-8PiolDP7.mjs → accept-CPVqki78.mjs} +0 -0
  234. /package/dist/{activity-listener-BtTSxZWA.mjs → activity-listener-C-BSbKI7.mjs} +0 -0
  235. /package/dist/{client-CAM_bQXx.d.cts → client-D-XP_Ccb.d.cts} +0 -0
  236. /package/dist/{client-CSddvgWN.d.ts → client-D-XP_Ccb.d.ts} +0 -0
  237. /package/dist/{keys-Dbyd7bhl.mjs → keys-DIJNHW-Y.mjs} +0 -0
  238. /package/dist/{kv-CXBAok3k.mjs → kv-DCHkaJ9-.mjs} +0 -0
  239. /package/dist/{negotiation-RuwrPFqi.mjs → negotiation-CCocWT_N.mjs} +0 -0
  240. /package/dist/{retry-DgFqeS5k.mjs → retry-zX5xw5e0.mjs} +0 -0
  241. /package/dist/{chunk-D5IdezsZ.js → rolldown-runtime-BC-Eo5Mj.js} +0 -0
  242. /package/dist/{chunk-Czpeue4M.cjs → rolldown-runtime-Dmm3t92W.cjs} +0 -0
  243. /package/dist/{transformers-DXze6FUB.js → transformers-DAtXl_Kc.js} +0 -0
  244. /package/dist/{types-CrtdVuDP.mjs → types-DE_LV1JB.mjs} +0 -0
@@ -1,11 +1,13 @@
1
1
  import { Temporal } from "temporal-polyfill";
2
2
  import "urlpattern-polyfill";
3
3
  globalThis.addEventListener = () => {};
4
- import { t as MemoryKvStore } from "../kv-CXBAok3k.mjs";
5
- import { t as assertEquals } from "../assert_equals-BV7oLMxm.mjs";
6
- import "../std__assert-B2NnY9bq.mjs";
7
- import { t as assertThrows } from "../assert_throws-NiFfqbnH.mjs";
8
- import { n as normalizeCircuitBreakerOptions, r as parseCircuitBreakerKvState, t as CircuitBreaker } from "../circuit-breaker-CBGyz5F7.mjs";
4
+ import { t as MemoryKvStore } from "../kv-DCHkaJ9-.mjs";
5
+ import { t as assertEquals } from "../assert_equals-Z6IcEbWU.mjs";
6
+ import { t as assertThrows } from "../assert_throws-BYITOvPO.mjs";
7
+ import "../std__assert-DvMDRPes.mjs";
8
+ import { t as assertRejects } from "../assert_rejects-CTptdYck.mjs";
9
+ import { n as normalizeCircuitBreakerOptions, r as parseCircuitBreakerKvState, t as CircuitBreaker } from "../circuit-breaker-XJ53md4S.mjs";
10
+ import { t as markCircuitBreakerLegacySweepDone } from "../circuit-breaker-test-utils-DW6YC_Pe.mjs";
9
11
  import { test } from "@fedify/fixture";
10
12
  //#region src/federation/circuit-breaker.test.ts
11
13
  var AlwaysConflictingKvStore = class extends MemoryKvStore {
@@ -18,15 +20,174 @@ var AlwaysConflictingKvStore = class extends MemoryKvStore {
18
20
  };
19
21
  var CountingCasKvStore = class extends MemoryKvStore {
20
22
  attempts = 0;
23
+ options = [];
21
24
  cas(key, expectedValue, newValue, options) {
22
25
  this.attempts++;
26
+ this.options.push(options);
23
27
  return super.cas(key, expectedValue, newValue, options);
24
28
  }
25
29
  };
30
+ var EncodedCasKvStore = class extends MemoryKvStore {
31
+ async cas(key, expectedValue, newValue, options) {
32
+ const current = await this.get(key);
33
+ if (JSON.stringify(current) !== JSON.stringify(expectedValue)) return false;
34
+ if (newValue === void 0) await this.delete(key);
35
+ else await this.set(key, newValue, options);
36
+ return true;
37
+ }
38
+ };
39
+ test("CircuitBreaker uses raw encoded CAS expectations", async () => {
40
+ const kv = new EncodedCasKvStore();
41
+ await markCircuitBreakerLegacySweepDone(kv);
42
+ let now = Temporal.Instant.from("2026-05-25T00:00:00Z");
43
+ const circuit = new CircuitBreaker({
44
+ kv,
45
+ prefix: ["_fedify", "circuit"],
46
+ now: () => now,
47
+ options: {
48
+ failureThreshold: 1,
49
+ recoveryDelay: { seconds: 30 }
50
+ }
51
+ });
52
+ const key = [
53
+ "_fedify",
54
+ "circuit",
55
+ "remote.example"
56
+ ];
57
+ await circuit.recordFailure("remote.example");
58
+ now = now.add({ seconds: 30 });
59
+ assertEquals((await circuit.beforeSend("remote.example", {})).type, "send");
60
+ assertEquals((await circuit.recordSuccess("remote.example"))?.newState, "closed");
61
+ const oldOrder = {
62
+ state: "half-open",
63
+ failures: ["2026-05-25T00:00:00Z"],
64
+ opened: "2026-05-25T00:00:00Z",
65
+ __fedifyCircuitBreakerStateVersion: 1,
66
+ halfOpened: "2026-05-25T00:00:00Z"
67
+ };
68
+ await kv.set(key, oldOrder);
69
+ assertEquals((await circuit.recordFailure("remote.example"))?.newState, "open");
70
+ await kv.set(key, oldOrder);
71
+ assertEquals((await circuit.recordSuccess("remote.example"))?.newState, "closed");
72
+ await kv.set(key, oldOrder);
73
+ assertEquals((await circuit.beforeSend("remote.example", {})).type, "send");
74
+ await kv.set(key, { __fedifyDeletingCircuitBreakerLegacyState: true });
75
+ await assertRejects(() => circuit.recordFailure("remote.example"));
76
+ assertEquals(await kv.get(key), { __fedifyDeletingCircuitBreakerLegacyState: true });
77
+ });
78
+ var CountingSetKvStore = class {
79
+ #store = new MemoryKvStore();
80
+ keys = [];
81
+ options = [];
82
+ get(key) {
83
+ return this.#store.get(key);
84
+ }
85
+ set(key, value, options) {
86
+ this.keys.push(key);
87
+ this.options.push(options);
88
+ return this.#store.set(key, value, options);
89
+ }
90
+ delete(key) {
91
+ return this.#store.delete(key);
92
+ }
93
+ list(prefix) {
94
+ return this.#store.list(prefix);
95
+ }
96
+ };
97
+ var CountingSweepKvStore = class extends MemoryKvStore {
98
+ listCalls = 0;
99
+ markerGetCalls = 0;
100
+ casCalls = [];
101
+ deletedKeys = [];
102
+ get(key) {
103
+ if (key.length === 4 && key[0] === "_fedify" && key[1] === "circuit" && key[2] === "__fedify_meta" && key[3] === "circuit_breaker_state_ttl_sweep_v2") this.markerGetCalls++;
104
+ return super.get(key);
105
+ }
106
+ cas(key, expectedValue, newValue, options) {
107
+ this.casCalls.push({
108
+ key,
109
+ options
110
+ });
111
+ return super.cas(key, expectedValue, newValue, options);
112
+ }
113
+ async *list(prefix) {
114
+ this.listCalls++;
115
+ yield* super.list(prefix);
116
+ }
117
+ delete(key) {
118
+ this.deletedKeys.push(key);
119
+ return super.delete(key);
120
+ }
121
+ };
122
+ var ConflictingSweepMarkerKvStore = class extends CountingSweepKvStore {
123
+ attempts = 0;
124
+ cas(key, expectedValue, newValue, options) {
125
+ if (key.length === 4 && key[0] === "_fedify" && key[1] === "circuit" && key[2] === "__fedify_meta" && key[3] === "circuit_breaker_state_ttl_sweep_v2") {
126
+ this.attempts++;
127
+ if (this.attempts > 3) throw new Error("legacy sweep did not stop retrying CAS misses");
128
+ return Promise.resolve(false);
129
+ }
130
+ return super.cas(key, expectedValue, newValue, options);
131
+ }
132
+ };
133
+ var ClockAdvancingSweepKvStore = class extends CountingSweepKvStore {
134
+ clock = Temporal.Instant.from("2026-05-25T00:00:00Z");
135
+ cas(key, expectedValue, newValue, options) {
136
+ if (key.length === 3) this.clock = this.clock.add({ seconds: 30 });
137
+ return super.cas(key, expectedValue, newValue, options);
138
+ }
139
+ };
140
+ var RenewalLostSweepKvStore = class extends ClockAdvancingSweepKvStore {
141
+ markerCasAttempts = 0;
142
+ cas(key, expectedValue, newValue, options) {
143
+ if (key.length === 4 && key[3] === "circuit_breaker_state_ttl_sweep_v2") {
144
+ this.markerCasAttempts++;
145
+ if (this.markerCasAttempts > 1) return Promise.resolve(false);
146
+ }
147
+ return super.cas(key, expectedValue, newValue, options);
148
+ }
149
+ };
150
+ var MarkerChangedDuringListKvStore = class extends CountingSweepKvStore {
151
+ async *list(prefix) {
152
+ yield* super.list(prefix);
153
+ await super.set([
154
+ "_fedify",
155
+ "circuit",
156
+ "__fedify_meta",
157
+ "circuit_breaker_state_ttl_sweep_v2"
158
+ ], { state: "final" });
159
+ }
160
+ };
161
+ var UpdatingDuringListKvStore = class extends MemoryKvStore {
162
+ async *list(prefix) {
163
+ for await (const entry of super.list(prefix)) {
164
+ if (entry.key.length === 3 && entry.key[0] === "_fedify" && entry.key[1] === "circuit" && entry.key[2] === "racy.example") await super.set(entry.key, {
165
+ state: "closed",
166
+ failures: ["2026-05-25T00:01:00Z"]
167
+ });
168
+ yield entry;
169
+ }
170
+ }
171
+ };
172
+ var FailingOnceSweepKvStore = class extends MemoryKvStore {
173
+ listCalls = 0;
174
+ deletedKeys = [];
175
+ async *list(prefix) {
176
+ this.listCalls++;
177
+ if (this.listCalls === 1) throw new Error("transient list failure");
178
+ yield* super.list(prefix);
179
+ }
180
+ delete(key) {
181
+ this.deletedKeys.push(key);
182
+ return super.delete(key);
183
+ }
184
+ };
26
185
  test("normalizeCircuitBreakerOptions() uses numeric failure policy", () => {
27
186
  const options = normalizeCircuitBreakerOptions({
28
187
  failureThreshold: 3,
29
- failureWindow: { minutes: 10 }
188
+ failureWindow: { minutes: 10 },
189
+ heldActivityTtl: { hours: 1 },
190
+ recoveryDelay: { hours: 2 }
30
191
  });
31
192
  const failures = [
32
193
  Temporal.Instant.from("2026-05-25T00:00:00Z"),
@@ -51,6 +212,7 @@ test("normalizeCircuitBreakerOptions() uses numeric failure policy", () => {
51
212
  "2026-05-25T00:11:00Z",
52
213
  "2026-05-25T00:12:00Z"
53
214
  ]);
215
+ assertEquals(options.stateTtl, Temporal.Duration.from({ hours: 3 }));
54
216
  });
55
217
  test("normalizeCircuitBreakerOptions() validates numeric failure policy", () => {
56
218
  assertThrows(() => normalizeCircuitBreakerOptions({ failureThreshold: 0 }), TypeError, "failureThreshold");
@@ -68,6 +230,10 @@ test("normalizeCircuitBreakerOptions() validates positive durations", () => {
68
230
  assertThrows(() => normalizeCircuitBreakerOptions({ failureWindow: { seconds: 0 } }), RangeError, "failureWindow");
69
231
  assertThrows(() => normalizeCircuitBreakerOptions({ releaseInterval: { seconds: 0 } }), RangeError, "releaseInterval");
70
232
  assertThrows(() => normalizeCircuitBreakerOptions({ releaseInterval: { nanoseconds: 5e5 } }), RangeError, "releaseInterval");
233
+ assertThrows(() => normalizeCircuitBreakerOptions({
234
+ recoveryDelay: { minutes: 30 },
235
+ stateTtl: { minutes: 29 }
236
+ }), RangeError, "stateTtl");
71
237
  });
72
238
  test("normalizeCircuitBreakerOptions() accepts callback failure policy", () => {
73
239
  const options = normalizeCircuitBreakerOptions({ failure: (timestamps) => timestamps.length >= 2 });
@@ -76,13 +242,26 @@ test("normalizeCircuitBreakerOptions() accepts callback failure policy", () => {
76
242
  assertEquals(options.failure([Temporal.Instant.from("2026-05-25T00:00:00Z")]), false);
77
243
  assertEquals(options.failure([Temporal.Instant.from("2026-05-25T00:00:00Z"), Temporal.Instant.from("2026-05-25T00:01:00Z")]), true);
78
244
  assertEquals(options.pruneFailures(failures, base.add({ minutes: 105 })).map((t) => t.toString()), failures.slice(-100).map((t) => t.toString()));
245
+ assertEquals(options.stateTtl?.total("millisecond"), Temporal.Duration.from({
246
+ days: 7,
247
+ minutes: 30
248
+ }).total("millisecond"));
249
+ assertEquals(normalizeCircuitBreakerOptions({
250
+ failure: (timestamps) => timestamps.length >= 2,
251
+ recoveryDelay: { hours: 1 },
252
+ heldActivityTtl: { days: 2 }
253
+ }).stateTtl?.total("millisecond"), Temporal.Duration.from({
254
+ days: 2,
255
+ hours: 1
256
+ }).total("millisecond"));
79
257
  });
80
258
  test("parseCircuitBreakerKvState() validates stored shape", () => {
81
259
  assertEquals(parseCircuitBreakerKvState({
82
260
  state: "open",
83
261
  failures: ["2026-05-25T00:00:00Z"],
84
262
  opened: "2026-05-25T00:00:00Z",
85
- halfOpened: "2026-05-25T00:00:00Z"
263
+ halfOpened: "2026-05-25T00:00:00Z",
264
+ __fedifyCircuitBreakerStateVersion: 1
86
265
  }), {
87
266
  state: "open",
88
267
  failures: ["2026-05-25T00:00:00Z"],
@@ -192,6 +371,7 @@ test("CircuitBreaker opens, probes, closes, and drops held activities", async ()
192
371
  });
193
372
  test("CircuitBreaker recovers stale half-open probes", async () => {
194
373
  const kv = new MemoryKvStore();
374
+ await markCircuitBreakerLegacySweepDone(kv);
195
375
  let now = Temporal.Instant.from("2026-05-25T00:00:00Z");
196
376
  const circuit = new CircuitBreaker({
197
377
  kv,
@@ -240,6 +420,7 @@ test("CircuitBreaker recovers stale half-open probes", async () => {
240
420
  });
241
421
  test("CircuitBreaker caps held delays at activity TTL", async () => {
242
422
  const kv = new MemoryKvStore();
423
+ await markCircuitBreakerLegacySweepDone(kv);
243
424
  const now = Temporal.Instant.from("2026-05-25T00:05:00Z");
244
425
  const circuit = new CircuitBreaker({
245
426
  kv,
@@ -303,6 +484,7 @@ test("CircuitBreaker caps held delays at activity TTL", async () => {
303
484
  });
304
485
  test("CircuitBreaker ignores malformed held timestamps", async () => {
305
486
  const kv = new MemoryKvStore();
487
+ await markCircuitBreakerLegacySweepDone(kv);
306
488
  const now = Temporal.Instant.from("2026-05-25T00:05:00Z");
307
489
  const circuit = new CircuitBreaker({
308
490
  kv,
@@ -328,6 +510,7 @@ test("CircuitBreaker ignores malformed held timestamps", async () => {
328
510
  });
329
511
  test("CircuitBreaker bounds beforeSend CAS retries", async () => {
330
512
  let kv = new AlwaysConflictingKvStore();
513
+ await markCircuitBreakerLegacySweepDone(kv);
331
514
  const now = Temporal.Instant.from("2026-05-25T00:30:00Z");
332
515
  let circuit = new CircuitBreaker({
333
516
  kv,
@@ -356,6 +539,7 @@ test("CircuitBreaker bounds beforeSend CAS retries", async () => {
356
539
  heldSince: now
357
540
  });
358
541
  kv = new AlwaysConflictingKvStore();
542
+ await markCircuitBreakerLegacySweepDone(kv);
359
543
  circuit = new CircuitBreaker({
360
544
  kv,
361
545
  prefix: ["_fedify", "circuit"],
@@ -386,6 +570,7 @@ test("CircuitBreaker bounds beforeSend CAS retries", async () => {
386
570
  });
387
571
  test("CircuitBreaker skips recording failures for open circuits", async () => {
388
572
  const kv = new CountingCasKvStore();
573
+ await markCircuitBreakerLegacySweepDone(kv);
389
574
  const circuit = new CircuitBreaker({
390
575
  kv,
391
576
  prefix: ["_fedify", "circuit"],
@@ -412,6 +597,652 @@ test("CircuitBreaker skips recording failures for open circuits", async () => {
412
597
  opened: "2026-05-25T00:00:00Z"
413
598
  });
414
599
  });
600
+ test("CircuitBreaker writes stored states with a TTL", async () => {
601
+ const kv = new CountingCasKvStore();
602
+ await markCircuitBreakerLegacySweepDone(kv);
603
+ await new CircuitBreaker({
604
+ kv,
605
+ prefix: ["_fedify", "circuit"],
606
+ now: () => Temporal.Instant.from("2026-05-25T00:00:00Z"),
607
+ options: {
608
+ failureThreshold: 2,
609
+ failureWindow: { hours: 3 },
610
+ recoveryDelay: { hours: 1 },
611
+ heldActivityTtl: { days: 2 }
612
+ }
613
+ }).recordFailure("remote.example");
614
+ assertEquals(kv.options.at(-1), { ttl: Temporal.Duration.from({
615
+ days: 2,
616
+ hours: 1
617
+ }) });
618
+ });
619
+ test("CircuitBreaker writes stored states with a TTL without CAS", async () => {
620
+ const kv = new CountingSetKvStore();
621
+ await new CircuitBreaker({
622
+ kv,
623
+ prefix: ["_fedify", "circuit"],
624
+ now: () => Temporal.Instant.from("2026-05-25T00:00:00Z"),
625
+ options: {
626
+ failureThreshold: 2,
627
+ failureWindow: { hours: 3 },
628
+ recoveryDelay: { hours: 1 },
629
+ heldActivityTtl: { days: 2 }
630
+ }
631
+ }).recordFailure("remote.example");
632
+ assertEquals(kv.keys, [[
633
+ "_fedify",
634
+ "circuit",
635
+ "remote.example"
636
+ ]]);
637
+ assertEquals(kv.options.at(-1), { ttl: Temporal.Duration.from({
638
+ days: 2,
639
+ hours: 1
640
+ }) });
641
+ });
642
+ test("CircuitBreaker writes custom-policy states with a derived TTL", async () => {
643
+ const kv = new CountingCasKvStore();
644
+ await markCircuitBreakerLegacySweepDone(kv);
645
+ await new CircuitBreaker({
646
+ kv,
647
+ prefix: ["_fedify", "circuit"],
648
+ now: () => Temporal.Instant.from("2026-05-25T00:00:00Z"),
649
+ options: { failure: (timestamps) => timestamps.length >= 2 }
650
+ }).recordFailure("remote.example");
651
+ assertEquals(kv.options.at(-1)?.ttl?.total("millisecond"), Temporal.Duration.from({
652
+ days: 7,
653
+ minutes: 30
654
+ }).total("millisecond"));
655
+ });
656
+ test("CircuitBreaker accepts an explicit TTL with custom failure policies", async () => {
657
+ const kv = new CountingCasKvStore();
658
+ await markCircuitBreakerLegacySweepDone(kv);
659
+ await new CircuitBreaker({
660
+ kv,
661
+ prefix: ["_fedify", "circuit"],
662
+ now: () => Temporal.Instant.from("2026-05-25T00:00:00Z"),
663
+ options: {
664
+ failure: (timestamps) => timestamps.length >= 2,
665
+ stateTtl: { days: 14 }
666
+ }
667
+ }).recordFailure("remote.example");
668
+ assertEquals(kv.options.at(-1), { ttl: Temporal.Duration.from({ days: 14 }) });
669
+ });
670
+ test("CircuitBreaker sweeps legacy states for custom failure policies", async () => {
671
+ const kv = new CountingSweepKvStore();
672
+ await kv.set([
673
+ "_fedify",
674
+ "circuit",
675
+ "stale.example"
676
+ ], {
677
+ state: "closed",
678
+ failures: ["2026-05-25T00:00:00Z"]
679
+ });
680
+ const circuit = new CircuitBreaker({
681
+ kv,
682
+ prefix: ["_fedify", "circuit"],
683
+ now: () => Temporal.Instant.from("2026-05-25T00:00:00Z"),
684
+ options: { failure: (timestamps) => timestamps.length >= 2 }
685
+ });
686
+ await circuit.recordFailure("remote.example");
687
+ await circuit.pendingSweep;
688
+ assertEquals(kv.listCalls, 1);
689
+ assertEquals(await kv.get([
690
+ "_fedify",
691
+ "circuit",
692
+ "stale.example"
693
+ ]), {
694
+ state: "closed",
695
+ failures: ["2026-05-25T00:00:00Z"],
696
+ __fedifyCircuitBreakerStateVersion: 1
697
+ });
698
+ assertEquals(kv.casCalls.find(({ key }) => key.at(-1) === "stale.example")?.options?.ttl?.total("millisecond"), Temporal.Duration.from({
699
+ days: 7,
700
+ minutes: 30
701
+ }).total("millisecond"));
702
+ });
703
+ test("CircuitBreaker stamps a TTL on versioned states during sweeps", async () => {
704
+ const kv = new CountingSweepKvStore();
705
+ await kv.set([
706
+ "_fedify",
707
+ "circuit",
708
+ "lingering.example"
709
+ ], {
710
+ state: "closed",
711
+ failures: ["2026-05-25T00:00:00Z"],
712
+ __fedifyCircuitBreakerStateVersion: 1
713
+ });
714
+ const circuit = new CircuitBreaker({
715
+ kv,
716
+ prefix: ["_fedify", "circuit"],
717
+ now: () => Temporal.Instant.from("2026-05-25T00:00:00Z"),
718
+ options: { failure: (timestamps) => timestamps.length >= 2 }
719
+ });
720
+ await circuit.recordFailure("remote.example");
721
+ await circuit.pendingSweep;
722
+ assertEquals(await kv.get([
723
+ "_fedify",
724
+ "circuit",
725
+ "lingering.example"
726
+ ]), {
727
+ state: "closed",
728
+ failures: ["2026-05-25T00:00:00Z"],
729
+ __fedifyCircuitBreakerStateVersion: 1
730
+ });
731
+ assertEquals(kv.casCalls.find(({ key }) => key.at(-1) === "lingering.example")?.options?.ttl?.total("millisecond"), Temporal.Duration.from({
732
+ days: 7,
733
+ minutes: 30
734
+ }).total("millisecond"));
735
+ });
736
+ test("CircuitBreaker re-sweeps despite a completed v1 sweep marker", async () => {
737
+ const kv = new CountingSweepKvStore();
738
+ await kv.set([
739
+ "_fedify",
740
+ "circuit",
741
+ "__fedify_meta",
742
+ "circuit_breaker_state_ttl_sweep_v1"
743
+ ], { state: "final" });
744
+ await kv.set([
745
+ "_fedify",
746
+ "circuit",
747
+ "lingering.example"
748
+ ], {
749
+ state: "closed",
750
+ failures: ["2026-05-25T00:00:00Z"],
751
+ __fedifyCircuitBreakerStateVersion: 1
752
+ });
753
+ const circuit = new CircuitBreaker({
754
+ kv,
755
+ prefix: ["_fedify", "circuit"],
756
+ now: () => Temporal.Instant.from("2026-05-25T00:00:00Z"),
757
+ options: { failure: (timestamps) => timestamps.length >= 2 }
758
+ });
759
+ await circuit.recordFailure("remote.example");
760
+ await circuit.pendingSweep;
761
+ assertEquals(kv.listCalls, 1);
762
+ assertEquals(kv.casCalls.find(({ key }) => key.at(-1) === "lingering.example")?.options?.ttl?.total("millisecond"), Temporal.Duration.from({
763
+ days: 7,
764
+ minutes: 30
765
+ }).total("millisecond"));
766
+ });
767
+ test("CircuitBreaker renews the sweep marker lease during long sweeps", async () => {
768
+ const kv = new ClockAdvancingSweepKvStore();
769
+ for (let i = 0; i < 5; i++) await kv.set([
770
+ "_fedify",
771
+ "circuit",
772
+ `host-${i}.example`
773
+ ], {
774
+ state: "closed",
775
+ failures: ["2026-05-25T00:00:00Z"],
776
+ __fedifyCircuitBreakerStateVersion: 1
777
+ });
778
+ const circuit = new CircuitBreaker({
779
+ kv,
780
+ prefix: ["_fedify", "circuit"],
781
+ now: () => kv.clock,
782
+ options: { failure: (timestamps) => timestamps.length >= 2 }
783
+ });
784
+ await circuit.getState("remote.example");
785
+ await circuit.pendingSweep;
786
+ assertEquals(kv.casCalls.filter(({ key }) => key.at(-1) === "circuit_breaker_state_ttl_sweep_v2").length, 4);
787
+ assertEquals(await kv.get([
788
+ "_fedify",
789
+ "circuit",
790
+ "__fedify_meta",
791
+ "circuit_breaker_state_ttl_sweep_v2"
792
+ ]), {
793
+ state: "done",
794
+ retryUntil: "2026-06-01T00:00:00Z"
795
+ });
796
+ assertEquals(kv.casCalls.find(({ key }) => key.at(-1) === "host-4.example")?.options?.ttl?.total("millisecond"), Temporal.Duration.from({
797
+ days: 7,
798
+ minutes: 30
799
+ }).total("millisecond"));
800
+ });
801
+ test("CircuitBreaker abandons the sweep when the marker lease is lost", async () => {
802
+ const kv = new RenewalLostSweepKvStore();
803
+ for (let i = 0; i < 5; i++) await kv.set([
804
+ "_fedify",
805
+ "circuit",
806
+ `host-${i}.example`
807
+ ], {
808
+ state: "closed",
809
+ failures: ["2026-05-25T00:00:00Z"],
810
+ __fedifyCircuitBreakerStateVersion: 1
811
+ });
812
+ const circuit = new CircuitBreaker({
813
+ kv,
814
+ prefix: ["_fedify", "circuit"],
815
+ now: () => kv.clock,
816
+ options: { failure: (timestamps) => timestamps.length >= 2 }
817
+ });
818
+ await circuit.getState("remote.example");
819
+ await circuit.pendingSweep;
820
+ assertEquals(kv.markerCasAttempts, 2);
821
+ assertEquals(kv.casCalls.filter(({ key }) => key.at(-1) === "host-4.example").length, 0);
822
+ assertEquals((await kv.get([
823
+ "_fedify",
824
+ "circuit",
825
+ "__fedify_meta",
826
+ "circuit_breaker_state_ttl_sweep_v2"
827
+ ]))?.state, "sweeping");
828
+ });
829
+ test("CircuitBreaker migrates legacy states without TTL once", async () => {
830
+ const kv = new CountingSweepKvStore();
831
+ await kv.set([
832
+ "_fedify",
833
+ "circuit",
834
+ "stale-a.example"
835
+ ], {
836
+ state: "closed",
837
+ failures: ["2026-05-25T00:00:00Z"]
838
+ });
839
+ await kv.set([
840
+ "_fedify",
841
+ "circuit",
842
+ "stale-b.example"
843
+ ], {
844
+ state: "open",
845
+ failures: ["2026-05-25T00:00:00Z"],
846
+ opened: "2026-05-25T00:00:00Z"
847
+ });
848
+ await kv.set([
849
+ "_fedify",
850
+ "circuit",
851
+ "malformed.example"
852
+ ], "stale");
853
+ await kv.set([
854
+ "_fedify",
855
+ "other",
856
+ "untouched.example"
857
+ ], "keep");
858
+ await kv.set([
859
+ "_fedify",
860
+ "circuit",
861
+ "__fedify_meta",
862
+ "future"
863
+ ], {
864
+ state: "closed",
865
+ failures: ["2026-05-25T00:00:00Z"]
866
+ });
867
+ let now = Temporal.Instant.from("2026-05-25T00:00:00Z");
868
+ const circuit = new CircuitBreaker({
869
+ kv,
870
+ prefix: ["_fedify", "circuit"],
871
+ now: () => now,
872
+ options: { failureThreshold: 2 }
873
+ });
874
+ assertEquals(await circuit.beforeSend("stale-b.example", {}), {
875
+ type: "hold",
876
+ state: "open",
877
+ delay: Temporal.Duration.from({ minutes: 30 }),
878
+ heldSince: Temporal.Instant.from("2026-05-25T00:00:00Z")
879
+ });
880
+ await circuit.pendingSweep;
881
+ assertEquals(await kv.get([
882
+ "_fedify",
883
+ "circuit",
884
+ "stale-a.example"
885
+ ]), {
886
+ state: "closed",
887
+ failures: ["2026-05-25T00:00:00Z"],
888
+ __fedifyCircuitBreakerStateVersion: 1
889
+ });
890
+ assertEquals(await kv.get([
891
+ "_fedify",
892
+ "circuit",
893
+ "stale-b.example"
894
+ ]), {
895
+ state: "open",
896
+ failures: ["2026-05-25T00:00:00Z"],
897
+ opened: "2026-05-25T00:00:00Z",
898
+ __fedifyCircuitBreakerStateVersion: 1
899
+ });
900
+ assertEquals(await kv.get([
901
+ "_fedify",
902
+ "circuit",
903
+ "malformed.example"
904
+ ]), void 0);
905
+ assertEquals(await kv.get([
906
+ "_fedify",
907
+ "other",
908
+ "untouched.example"
909
+ ]), "keep");
910
+ assertEquals(await kv.get([
911
+ "_fedify",
912
+ "circuit",
913
+ "__fedify_meta",
914
+ "future"
915
+ ]), {
916
+ state: "closed",
917
+ failures: ["2026-05-25T00:00:00Z"]
918
+ });
919
+ await circuit.recordFailure("remote.example");
920
+ assertEquals(await circuit.getState("remote.example"), {
921
+ state: "closed",
922
+ failures: ["2026-05-25T00:00:00Z"]
923
+ });
924
+ assertEquals(kv.listCalls, 1);
925
+ assertEquals(kv.casCalls.find(({ key }) => key.length === 3 && key[0] === "_fedify" && key[1] === "circuit" && key[2] === "stale-a.example")?.options, { ttl: Temporal.Duration.from({ days: 7 }) });
926
+ assertEquals(kv.deletedKeys, [[
927
+ "_fedify",
928
+ "circuit",
929
+ "malformed.example"
930
+ ]]);
931
+ assertEquals(await kv.get([
932
+ "_fedify",
933
+ "circuit",
934
+ "__fedify_meta",
935
+ "circuit_breaker_state_ttl_sweep_v2"
936
+ ]), {
937
+ state: "done",
938
+ retryUntil: "2026-06-01T00:00:00Z"
939
+ });
940
+ await kv.set([
941
+ "_fedify",
942
+ "circuit",
943
+ "late-stale.example"
944
+ ], {
945
+ state: "closed",
946
+ failures: ["2026-05-25T00:00:00Z"]
947
+ });
948
+ await circuit.recordFailure("another.example");
949
+ assertEquals(await kv.get([
950
+ "_fedify",
951
+ "circuit",
952
+ "late-stale.example"
953
+ ]), {
954
+ state: "closed",
955
+ failures: ["2026-05-25T00:00:00Z"]
956
+ });
957
+ assertEquals(kv.listCalls, 1);
958
+ now = Temporal.Instant.from("2026-06-02T00:00:00Z");
959
+ await circuit.recordFailure("final.example");
960
+ await circuit.pendingSweep;
961
+ assertEquals(await kv.get([
962
+ "_fedify",
963
+ "circuit",
964
+ "late-stale.example"
965
+ ]), {
966
+ state: "closed",
967
+ failures: ["2026-05-25T00:00:00Z"],
968
+ __fedifyCircuitBreakerStateVersion: 1
969
+ });
970
+ assertEquals(await circuit.getState("another.example"), {
971
+ state: "closed",
972
+ failures: ["2026-05-25T00:00:00Z"]
973
+ });
974
+ assertEquals(kv.listCalls, 2);
975
+ assertEquals(await kv.get([
976
+ "_fedify",
977
+ "circuit",
978
+ "__fedify_meta",
979
+ "circuit_breaker_state_ttl_sweep_v2"
980
+ ]), { state: "final" });
981
+ });
982
+ test("CircuitBreaker does not sweep state changed after listing", async () => {
983
+ const kv = new UpdatingDuringListKvStore();
984
+ await kv.set([
985
+ "_fedify",
986
+ "circuit",
987
+ "racy.example"
988
+ ], {
989
+ state: "closed",
990
+ failures: ["2026-05-25T00:00:00Z"]
991
+ });
992
+ const circuit = new CircuitBreaker({
993
+ kv,
994
+ prefix: ["_fedify", "circuit"],
995
+ now: () => Temporal.Instant.from("2026-05-25T00:02:00Z"),
996
+ options: { failureThreshold: 2 }
997
+ });
998
+ await circuit.recordFailure("remote.example");
999
+ await circuit.pendingSweep;
1000
+ assertEquals(await kv.get([
1001
+ "_fedify",
1002
+ "circuit",
1003
+ "racy.example"
1004
+ ]), {
1005
+ state: "closed",
1006
+ failures: ["2026-05-25T00:01:00Z"]
1007
+ });
1008
+ });
1009
+ test("CircuitBreaker skips legacy sweep already running elsewhere", async () => {
1010
+ const kv = new CountingSweepKvStore();
1011
+ await kv.set([
1012
+ "_fedify",
1013
+ "circuit",
1014
+ "__fedify_meta",
1015
+ "circuit_breaker_state_ttl_sweep_v2"
1016
+ ], {
1017
+ state: "sweeping",
1018
+ started: "2026-05-25T00:00:00Z",
1019
+ retryUntil: "2026-06-01T00:00:00Z"
1020
+ });
1021
+ const circuit = new CircuitBreaker({
1022
+ kv,
1023
+ prefix: ["_fedify", "circuit"],
1024
+ now: () => Temporal.Instant.from("2026-05-25T00:00:00Z"),
1025
+ options: { failureThreshold: 2 }
1026
+ });
1027
+ await circuit.recordFailure("remote.example");
1028
+ assertEquals(kv.listCalls, 0);
1029
+ assertEquals(await circuit.getState("remote.example"), {
1030
+ state: "closed",
1031
+ failures: ["2026-05-25T00:00:00Z"]
1032
+ });
1033
+ });
1034
+ test("CircuitBreaker does not overwrite a changed legacy sweep marker", async () => {
1035
+ const kv = new MarkerChangedDuringListKvStore();
1036
+ await kv.set([
1037
+ "_fedify",
1038
+ "circuit",
1039
+ "stale.example"
1040
+ ], {
1041
+ state: "closed",
1042
+ failures: ["2026-05-25T00:00:00Z"]
1043
+ });
1044
+ const circuit = new CircuitBreaker({
1045
+ kv,
1046
+ prefix: ["_fedify", "circuit"],
1047
+ now: () => Temporal.Instant.from("2026-05-25T00:00:00Z"),
1048
+ options: { failureThreshold: 2 }
1049
+ });
1050
+ await circuit.recordFailure("remote.example");
1051
+ await circuit.pendingSweep;
1052
+ assertEquals(await kv.get([
1053
+ "_fedify",
1054
+ "circuit",
1055
+ "__fedify_meta",
1056
+ "circuit_breaker_state_ttl_sweep_v2"
1057
+ ]), { state: "final" });
1058
+ });
1059
+ test("CircuitBreaker bounds legacy sweep CAS retries", async () => {
1060
+ const kv = new ConflictingSweepMarkerKvStore();
1061
+ const circuit = new CircuitBreaker({
1062
+ kv,
1063
+ prefix: ["_fedify", "circuit"],
1064
+ now: () => Temporal.Instant.from("2026-05-25T00:00:00Z"),
1065
+ options: { failureThreshold: 2 }
1066
+ });
1067
+ await circuit.getState("remote.example");
1068
+ await circuit.pendingSweep;
1069
+ assertEquals(kv.attempts, 3);
1070
+ assertEquals(kv.listCalls, 0);
1071
+ });
1072
+ test("CircuitBreaker retries expired legacy sweep markers", async () => {
1073
+ const kv = new CountingSweepKvStore();
1074
+ await kv.set([
1075
+ "_fedify",
1076
+ "circuit",
1077
+ "__fedify_meta",
1078
+ "circuit_breaker_state_ttl_sweep_v2"
1079
+ ], {
1080
+ state: "sweeping",
1081
+ started: "2026-05-25T00:00:00Z",
1082
+ retryUntil: "2026-06-01T00:00:00Z"
1083
+ });
1084
+ await kv.set([
1085
+ "_fedify",
1086
+ "circuit",
1087
+ "stale.example"
1088
+ ], {
1089
+ state: "closed",
1090
+ failures: ["2026-05-25T00:00:00Z"]
1091
+ });
1092
+ const circuit = new CircuitBreaker({
1093
+ kv,
1094
+ prefix: ["_fedify", "circuit"],
1095
+ now: () => Temporal.Instant.from("2026-05-25T00:06:00Z"),
1096
+ options: { failureThreshold: 2 }
1097
+ });
1098
+ await circuit.recordFailure("remote.example");
1099
+ await circuit.pendingSweep;
1100
+ assertEquals(kv.listCalls, 1);
1101
+ assertEquals(await kv.get([
1102
+ "_fedify",
1103
+ "circuit",
1104
+ "stale.example"
1105
+ ]), {
1106
+ state: "closed",
1107
+ failures: ["2026-05-25T00:00:00Z"],
1108
+ __fedifyCircuitBreakerStateVersion: 1
1109
+ });
1110
+ assertEquals(await kv.get([
1111
+ "_fedify",
1112
+ "circuit",
1113
+ "__fedify_meta",
1114
+ "circuit_breaker_state_ttl_sweep_v2"
1115
+ ]), {
1116
+ state: "done",
1117
+ retryUntil: "2026-06-01T00:00:00Z"
1118
+ });
1119
+ });
1120
+ test("CircuitBreaker caches completed legacy sweep markers", async () => {
1121
+ const doneKv = new CountingSweepKvStore();
1122
+ await doneKv.set([
1123
+ "_fedify",
1124
+ "circuit",
1125
+ "__fedify_meta",
1126
+ "circuit_breaker_state_ttl_sweep_v2"
1127
+ ], {
1128
+ state: "done",
1129
+ retryUntil: "2026-06-01T00:00:00Z"
1130
+ });
1131
+ let now = Temporal.Instant.from("2026-05-25T00:00:00Z");
1132
+ const doneCircuit = new CircuitBreaker({
1133
+ kv: doneKv,
1134
+ prefix: ["_fedify", "circuit"],
1135
+ now: () => now,
1136
+ options: { failureThreshold: 2 }
1137
+ });
1138
+ await doneCircuit.recordFailure("remote.example");
1139
+ await doneCircuit.pendingSweep;
1140
+ await doneCircuit.recordFailure("another.example");
1141
+ await doneCircuit.pendingSweep;
1142
+ assertEquals(doneKv.markerGetCalls, 1);
1143
+ now = Temporal.Instant.from("2026-06-02T00:00:00Z");
1144
+ await doneCircuit.recordFailure("final.example");
1145
+ await doneCircuit.pendingSweep;
1146
+ assertEquals(doneKv.markerGetCalls, 2);
1147
+ const finalKv = new CountingSweepKvStore();
1148
+ await markCircuitBreakerLegacySweepDone(finalKv);
1149
+ const finalCircuit = new CircuitBreaker({
1150
+ kv: finalKv,
1151
+ prefix: ["_fedify", "circuit"],
1152
+ now: () => Temporal.Instant.from("2026-05-25T00:00:00Z"),
1153
+ options: { failureThreshold: 2 }
1154
+ });
1155
+ await finalCircuit.recordFailure("remote.example");
1156
+ await finalCircuit.pendingSweep;
1157
+ await finalCircuit.recordFailure("another.example");
1158
+ await finalCircuit.pendingSweep;
1159
+ assertEquals(finalKv.markerGetCalls, 1);
1160
+ });
1161
+ test("CircuitBreaker retries malformed legacy sweep markers", async () => {
1162
+ const kv = new CountingSweepKvStore();
1163
+ await kv.set([
1164
+ "_fedify",
1165
+ "circuit",
1166
+ "__fedify_meta",
1167
+ "circuit_breaker_state_ttl_sweep_v2"
1168
+ ], {
1169
+ state: "done",
1170
+ retryUntil: "not an instant"
1171
+ });
1172
+ await kv.set([
1173
+ "_fedify",
1174
+ "circuit",
1175
+ "stale.example"
1176
+ ], {
1177
+ state: "closed",
1178
+ failures: ["2026-05-25T00:00:00Z"]
1179
+ });
1180
+ const circuit = new CircuitBreaker({
1181
+ kv,
1182
+ prefix: ["_fedify", "circuit"],
1183
+ now: () => Temporal.Instant.from("2026-05-25T00:00:00Z"),
1184
+ options: { failureThreshold: 2 }
1185
+ });
1186
+ await circuit.recordFailure("remote.example");
1187
+ await circuit.pendingSweep;
1188
+ assertEquals(kv.listCalls, 1);
1189
+ assertEquals(await kv.get([
1190
+ "_fedify",
1191
+ "circuit",
1192
+ "stale.example"
1193
+ ]), {
1194
+ state: "closed",
1195
+ failures: ["2026-05-25T00:00:00Z"],
1196
+ __fedifyCircuitBreakerStateVersion: 1
1197
+ });
1198
+ assertEquals(await circuit.getState("remote.example"), {
1199
+ state: "closed",
1200
+ failures: ["2026-05-25T00:00:00Z"]
1201
+ });
1202
+ assertEquals(await kv.get([
1203
+ "_fedify",
1204
+ "circuit",
1205
+ "__fedify_meta",
1206
+ "circuit_breaker_state_ttl_sweep_v2"
1207
+ ]), {
1208
+ state: "done",
1209
+ retryUntil: "2026-06-01T00:00:00Z"
1210
+ });
1211
+ });
1212
+ test("CircuitBreaker retries legacy sweep after transient failures", async () => {
1213
+ const kv = new FailingOnceSweepKvStore();
1214
+ const circuit = new CircuitBreaker({
1215
+ kv,
1216
+ prefix: ["_fedify", "circuit"],
1217
+ now: () => Temporal.Instant.from("2026-05-25T00:00:00Z"),
1218
+ options: { failureThreshold: 2 }
1219
+ });
1220
+ await circuit.recordFailure("remote.example");
1221
+ await circuit.pendingSweep;
1222
+ assertEquals(await kv.get([
1223
+ "_fedify",
1224
+ "circuit",
1225
+ "__fedify_meta",
1226
+ "circuit_breaker_state_ttl_sweep_v2"
1227
+ ]), void 0);
1228
+ assertEquals(kv.deletedKeys, [[
1229
+ "_fedify",
1230
+ "circuit",
1231
+ "__fedify_meta",
1232
+ "circuit_breaker_state_ttl_sweep_v2"
1233
+ ]]);
1234
+ assertEquals(await circuit.getState("remote.example"), {
1235
+ state: "closed",
1236
+ failures: ["2026-05-25T00:00:00Z"]
1237
+ });
1238
+ await circuit.recordFailure("another.example");
1239
+ await circuit.pendingSweep;
1240
+ assertEquals(kv.listCalls, 2);
1241
+ assertEquals(await circuit.getState("another.example"), {
1242
+ state: "closed",
1243
+ failures: ["2026-05-25T00:00:00Z"]
1244
+ });
1245
+ });
415
1246
  test("CircuitBreaker prunes stale closed failure history", async () => {
416
1247
  const kv = new MemoryKvStore();
417
1248
  let now = Temporal.Instant.from("2026-05-25T00:00:00Z");