nanoeffects 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/{action-creator-DprJV-pN.d.ts → action-creator-CXIq6GWK.d.ts} +1 -1
  2. package/dist/actions.d.ts +3 -3
  3. package/dist/{context-types-BiTMp_lW.d.ts → context-types-BO1QE6W2.d.ts} +1 -1
  4. package/dist/context.d.ts +2 -2
  5. package/dist/dev.d.ts +2 -2
  6. package/dist/effects.d.ts +2 -2
  7. package/dist/guard.d.ts +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/match.d.ts +1 -1
  10. package/dist/{module-Fk4OtkZk.d.ts → module-BXNaEXE9.d.ts} +2 -2
  11. package/dist/notrace/{action-creator-BWd9FWt6.d.ts → action-creator-JUwIsFUl.d.ts} +1 -1
  12. package/dist/notrace/actions.d.ts +2 -2
  13. package/dist/notrace/effects.d.ts +2 -2
  14. package/dist/notrace/index.d.ts +1 -1
  15. package/dist/notrace/{plugin-DmAYT3Xk.d.ts → plugin-dLdTM2JL.d.ts} +1 -1
  16. package/dist/notrace/policies.d.ts +158 -2
  17. package/dist/notrace/policies.js +1 -1
  18. package/dist/notrace/redux.d.ts +1 -1
  19. package/dist/observable.d.ts +1 -1
  20. package/dist/oop.d.ts +1 -1
  21. package/dist/{plugin-2d3uuW7o.d.ts → plugin-B8La3K9m.d.ts} +2 -2
  22. package/dist/policies.d.ts +159 -3
  23. package/dist/policies.js +1 -1
  24. package/dist/react.d.ts +2 -2
  25. package/dist/redux.d.ts +1 -1
  26. package/dist/scope.d.ts +2 -2
  27. package/dist/signal.d.ts +2 -2
  28. package/dist/svelte.d.ts +1 -1
  29. package/dist/testing.d.ts +1 -1
  30. package/dist/trace.d.ts +1 -1
  31. package/dist/{types-DxPZDzZG.d.ts → types-ByoeMqVh.d.ts} +2 -2
  32. package/dist/vue.d.ts +1 -1
  33. package/dist/{with-policy-Y12d-yx6.d.ts → with-policy-e4yx1vhN.d.ts} +1 -1
  34. package/docs/package.md +1 -1
  35. package/docs/policies.md +188 -0
  36. package/docs/recipes.md +90 -0
  37. package/docs/ru/package.md +1 -1
  38. package/docs/ru/policies.md +188 -0
  39. package/docs/ru/recipes.md +90 -0
  40. package/llms-full.txt +279 -1
  41. package/llms.txt +1 -1
  42. package/package.json +2 -2
package/dist/react.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { H as EventMap, T as EventKey, a as FreeActionCreator, q as Task, t as ActionCreator, v as Bus } from "./action-creator-DprJV-pN.js";
1
+ import { H as EventMap, T as EventKey, a as FreeActionCreator, q as Task, t as ActionCreator, v as Bus } from "./action-creator-CXIq6GWK.js";
2
2
  import { t as OriginOptions } from "./origin-key-CxK0SkeG.js";
3
- import { t as PolicyFunction } from "./with-policy-Y12d-yx6.js";
3
+ import { t as PolicyFunction } from "./with-policy-e4yx1vhN.js";
4
4
  import { t as StateOf } from "./state-of-C19H6BoV.js";
5
5
  //#region src/react/plugin.d.ts
6
6
  /** Tells whether two values are the same: `Object.is` unless the caller gives another. */
package/dist/redux.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { H as EventMap, v as Bus } from "./action-creator-DprJV-pN.js";
1
+ import { H as EventMap, v as Bus } from "./action-creator-CXIq6GWK.js";
2
2
  import { t as OriginOptions } from "./origin-key-CxK0SkeG.js";
3
3
  //#region src/store/redux/middleware.d.ts
4
4
  /** What Redux passes through a middleware: an action is a plain object with a `type`. */
package/dist/scope.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as EventMap, I as WithSignal, c as Scope, f as Effect, m as Signaled, p as Effects, s as EmitScope, v as Bus } from "./action-creator-DprJV-pN.js";
2
- import { n as ScopeSlots, r as Scoped } from "./types-DxPZDzZG.js";
1
+ import { H as EventMap, I as WithSignal, c as Scope, f as Effect, m as Signaled, p as Effects, s as EmitScope, v as Bus } from "./action-creator-CXIq6GWK.js";
2
+ import { n as ScopeSlots, r as Scoped } from "./types-ByoeMqVh.js";
3
3
  //#region src/scope/effect.d.ts
4
4
  /**
5
5
  * Marks a function whose first parameter is `{ signal }` as an effect, so that
package/dist/signal.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { A as HandledCall, C as Emit, E as EventLike, H as EventMap, I as WithSignal, s as EmitScope, u as WithScope, v as Bus } from "./action-creator-DprJV-pN.js";
2
- import { n as FromSignal } from "./context-types-BiTMp_lW.js";
1
+ import { A as HandledCall, C as Emit, E as EventLike, H as EventMap, I as WithSignal, s as EmitScope, u as WithScope, v as Bus } from "./action-creator-CXIq6GWK.js";
2
+ import { n as FromSignal } from "./context-types-BO1QE6W2.js";
3
3
  //#region src/signal/fetch.d.ts
4
4
  /**
5
5
  * A `fetch` that stops with its task: an effect, so `ctx` is the first argument and the request carries
package/dist/svelte.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { H as EventMap, T as EventKey, a as FreeActionCreator, t as ActionCreator, v as Bus } from "./action-creator-DprJV-pN.js";
1
+ import { H as EventMap, T as EventKey, a as FreeActionCreator, t as ActionCreator, v as Bus } from "./action-creator-CXIq6GWK.js";
2
2
  import { t as StateOf } from "./state-of-C19H6BoV.js";
3
3
  import { Readable } from "svelte/store";
4
4
  //#region src/svelte/plugin.d.ts
package/dist/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { H as EventMap, v as Bus, y as BusOptions } from "./action-creator-DprJV-pN.js";
1
+ import { H as EventMap, v as Bus, y as BusOptions } from "./action-creator-CXIq6GWK.js";
2
2
  //#region src/testing/clock.d.ts
3
3
  /** What the runner needs from time: move it forward, and read it. */
4
4
  type Clock = {
package/dist/trace.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { B as Tracer, H as EventMap, J as TaskInfo, K as Phase, V as EffectName, X as TraceEvent, Y as Trace, Z as TraceLogger, v as Bus } from "./action-creator-DprJV-pN.js";
1
+ import { B as Tracer, H as EventMap, J as TaskInfo, K as Phase, V as EffectName, X as TraceEvent, Y as Trace, Z as TraceLogger, v as Bus } from "./action-creator-CXIq6GWK.js";
2
2
  //#region src/graph/serialize.d.ts
3
3
  type JsonValue = null | boolean | number | string | JsonValue[] | {
4
4
  [key: string]: JsonValue;
@@ -1,5 +1,5 @@
1
- import { H as EventMap, c as Scope, l as WithFields, p as Effects } from "./action-creator-DprJV-pN.js";
2
- import { t as BoundOf } from "./plugin-2d3uuW7o.js";
1
+ import { H as EventMap, c as Scope, l as WithFields, p as Effects } from "./action-creator-CXIq6GWK.js";
2
+ import { t as BoundOf } from "./plugin-B8La3K9m.js";
3
3
  //#region src/scope/types.d.ts
4
4
  /**
5
5
  * The slots `scopePlugin` fills for `modulePlugin`. They are not on `Bus`: a bus
package/dist/vue.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { H as EventMap, T as EventKey, a as FreeActionCreator, t as ActionCreator, v as Bus } from "./action-creator-DprJV-pN.js";
1
+ import { H as EventMap, T as EventKey, a as FreeActionCreator, t as ActionCreator, v as Bus } from "./action-creator-CXIq6GWK.js";
2
2
  import { t as OriginOptions } from "./origin-key-CxK0SkeG.js";
3
3
  import { t as StateOf } from "./state-of-C19H6BoV.js";
4
4
  import { ShallowRef, WatchSource, WatchStopHandle } from "vue";
@@ -1,4 +1,4 @@
1
- import { G as PayloadOf, H as EventMap, q as Task } from "./action-creator-DprJV-pN.js";
1
+ import { G as PayloadOf, H as EventMap, q as Task } from "./action-creator-CXIq6GWK.js";
2
2
  //#region src/policies/with-policy.d.ts
3
3
  /** What `withPolicy` returns: the task as a plain function that can be cancelled. */
4
4
  type PolicyFunction<P, R> = {
package/docs/package.md CHANGED
@@ -9,7 +9,7 @@ Imports are split so you only load what you use. `createBus` stays small; the re
9
9
  | Import | What is in it |
10
10
  | ------ | ------------- |
11
11
  | `nanoeffects` | `createBus`, the types |
12
- | `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue` |
12
+ | `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue`, `dedupe`, `distinct`, `rateLimit`, `batch` |
13
13
  | `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
14
14
 
15
15
  ## Add the rest when you need it
package/docs/policies.md CHANGED
@@ -6,6 +6,30 @@ Use this page when handlers overlap in a SPA (search-as-you-type, double submit,
6
6
 
7
7
  Start with `latest`. The table and sections below are the rest of the reference.
8
8
 
9
+ ## On this page
10
+
11
+ - [`latest`: abort the previous run](#latest-abort-the-previous-run)
12
+ - [Choose a policy](#choose-a-policy)
13
+ - [Look up a policy](#look-up-a-policy)
14
+ - [`latest`](#latest)
15
+ - [`leading`](#leading)
16
+ - [`debounce`](#debounce)
17
+ - [`throttle`](#throttle)
18
+ - [`queue`](#queue)
19
+ - [`dedupe`](#dedupe)
20
+ - [`distinct`](#distinct)
21
+ - [`rateLimit`](#ratelimit)
22
+ - [`batch`](#batch)
23
+ - [`key`](#key)
24
+ - [Configure debounce and throttle](#configure-debounce-and-throttle)
25
+ - [`key`: isolate state by key](#key-isolate-state-by-key)
26
+ - [Run calls one after another with `queue`](#run-calls-one-after-another-with-queue)
27
+ - [`dedupe`: share one run between equal calls](#dedupe-share-one-run-between-equal-calls)
28
+ - [`distinct`: skip a repeated payload](#distinct-skip-a-repeated-payload)
29
+ - [`rateLimit`: stay under a quota](#ratelimit-stay-under-a-quota)
30
+ - [`batch`: send many calls as one](#batch-send-many-calls-as-one)
31
+ - [`withPolicy`: call a policy outside a bus](#withpolicy-call-a-policy-outside-a-bus)
32
+
9
33
  ## `latest`: abort the previous run
10
34
 
11
35
  ```js
@@ -37,6 +61,10 @@ Every policy accepts `key` in its options (see [`key`: isolate state by key](#ke
37
61
  | `debounce(ms, fn, options?)` | run after `ms` of quiet |
38
62
  | `throttle(ms, fn, options?)` | at most one run per `ms` |
39
63
  | `queue(fn, options?)` | one after another, in order |
64
+ | `dedupe(fn, options?)` | calls with the same key share the run that is going |
65
+ | `distinct(fn, options?)` | a call with the same payload as the last one is skipped |
66
+ | `rateLimit(limit, ms, fn, options?)` | at most `limit` runs start per `ms`, the rest wait |
67
+ | `batch(ms, fn, options?)` | payloads of one window go to `fn` as one array |
40
68
 
41
69
  Each policy has a short entry in [Look up a policy](#look-up-a-policy).
42
70
 
@@ -84,6 +112,38 @@ Signature: `queue(fn, options?)`.
84
112
 
85
113
  More: [Run calls one after another with `queue`](#run-calls-one-after-another-with-queue).
86
114
 
115
+ ### `dedupe`
116
+
117
+ Calls with the same key that come while a run is going wait for that run and get its result. Nothing is cached.
118
+
119
+ Signature: `dedupe(fn, options?)`.
120
+
121
+ More: [`dedupe`: share one run between equal calls](#dedupe-share-one-run-between-equal-calls).
122
+
123
+ ### `distinct`
124
+
125
+ A call whose payload equals the last one is skipped and resolves with `undefined`.
126
+
127
+ Signature: `distinct(fn, options?)`.
128
+
129
+ More: [`distinct`: skip a repeated payload](#distinct-skip-a-repeated-payload).
130
+
131
+ ### `rateLimit`
132
+
133
+ At most `limit` runs start in any window of `ms`. The other calls wait for their turn.
134
+
135
+ Signature: `rateLimit(limit, ms, fn, options?)`.
136
+
137
+ More: [`rateLimit`: stay under a quota](#ratelimit-stay-under-a-quota).
138
+
139
+ ### `batch`
140
+
141
+ Collects the payloads that come in a window of `ms` and calls `fn` once with all of them.
142
+
143
+ Signature: `batch(ms, fn, options?)`.
144
+
145
+ More: [`batch`: send many calls as one](#batch-send-many-calls-as-one).
146
+
87
147
  ### `key`
88
148
 
89
149
  An option of every policy. It splits the state of the policy by a property, for example by id: calls with different keys do not affect each other.
@@ -169,6 +229,134 @@ Every call returns a promise with the result of its own run. An error rejects on
169
229
  - When a running call aborts, its slot is freed at once and its result is dropped.
170
230
  - Pass `signal` into what `fn` does. A `fn` that ignores it can overlap with the next run.
171
231
 
232
+ ## `dedupe`: share one run between equal calls
233
+
234
+ > **Level B or C.** Use it for reads that several parts of the screen start at the same time.
235
+
236
+ **Problem.** Three components ask for user `1` in the same tick. Without a policy that is three identical requests.
237
+
238
+ **When.** A read that is safe to repeat: a profile, a config, a list by id. For a write use [`leading`](prevent-double-submit.md) or [`queue`](#run-calls-one-after-another-with-queue): see the abort rule below.
239
+
240
+ `dedupe` starts `fn` for the first call. A call with the same key that comes while that run is going waits for it and gets its result, or its error. When the run ends, `dedupe` forgets it, and the next call starts `fn` again. It is not a cache: it never returns an old value.
241
+
242
+ ```js
243
+ import { dedupe, withPolicy } from "nanoeffects/policies";
244
+
245
+ // dedupe — callers with the same id share one request
246
+ const policy = dedupe((id, { signal }) => api.getUser(id, { signal }));
247
+
248
+ // withPolicy — a plain function, so the example shows the result of each call
249
+ const loadUser = withPolicy(policy);
250
+
251
+ // two callers, one request: both get the same user
252
+ const [first, second] = await Promise.all([loadUser(1), loadUser(1)]);
253
+ ```
254
+
255
+ On a bus it looks the same, `bus.on("user/load", dedupe(handler))`. To get the result of `emit` use an action or a job ([Actions](actions.md)).
256
+
257
+ | Option | Meaning |
258
+ | ------ | ------- |
259
+ | `key` | what makes two calls "the same". Default: the payload itself, compared like a `Map` key. Objects never match unless `key` says how. `key: () => "all"` shares one run between every call |
260
+
261
+ **Abort:**
262
+
263
+ - A caller whose task aborts resolves with `undefined`. The others go on.
264
+ - The run belongs to the task of the first caller. If that task aborts while others still wait, the run is aborted and started again with the next caller, so `fn` can run twice for one request. That is fine for a read and wrong for a write.
265
+ - Pass `signal` into what `fn` does, as for any policy.
266
+
267
+ ## `distinct`: skip a repeated payload
268
+
269
+ > **Level B.** Use it when the same value keeps arriving and repeating the work is a waste.
270
+
271
+ **Problem.** A form saves on every change. A user types a letter and deletes it, and the same text goes to the server again.
272
+
273
+ `distinct` compares the payload of each call with the payload of the last call it let through. An equal payload is skipped: `fn` does not run and the call resolves with `undefined`. It remembers one payload, never a result.
274
+
275
+ ```js
276
+ import { distinct, withPolicy } from "nanoeffects/policies";
277
+
278
+ // distinct — runs only when the text differs from the last one it let through
279
+ const policy = distinct(async (text) => {
280
+ await api.save(text);
281
+ });
282
+ const save = withPolicy(policy);
283
+
284
+ await save("hello"); // runs
285
+ await save("hello"); // skipped, resolves undefined
286
+ await save("hello!"); // runs: the text changed
287
+ ```
288
+
289
+ | Option | Meaning |
290
+ | ------ | ------- |
291
+ | `equals(previous, next)` | tells whether two payloads are the same. Default: `Object.is` |
292
+ | `key` | each key remembers its own payload |
293
+
294
+ - The payload counts as seen from the moment its run starts, so the same payload that arrives while the run goes is skipped too.
295
+ - It is forgotten when the run fails or its task aborts, so a retry with the same payload runs.
296
+ - The memory of a key stays after the run. Unlike other policies, `distinct` does not drop idle keys, so give `key` values from a bounded set (a form id, not a request id).
297
+
298
+ ## `rateLimit`: stay under a quota
299
+
300
+ > **Level C.** Use it when an API allows N requests per period.
301
+
302
+ **Problem.** A geocoding API allows 5 requests per second. A list of 40 addresses sends 40 requests at once, and most of them fail with `429`.
303
+
304
+ `rateLimit(limit, ms, fn)` lets at most `limit` runs start in any window of `ms` milliseconds. The window slides: it is counted back from each call. A call over the limit waits and starts, in order, when the oldest start leaves the window.
305
+
306
+ ```js
307
+ import { rateLimit, withPolicy } from "nanoeffects/policies";
308
+
309
+ // rateLimit — 5 runs may start per second, the rest wait their turn
310
+ const policy = rateLimit(5, 1000, (address, { signal }) => {
311
+ return api.geocode(address, { signal });
312
+ });
313
+ const geocode = withPolicy(policy);
314
+
315
+ const addresses = ["10 Main St", "22 Oak Ave", "5 Pine Rd", "8 Elm St", "3 Lake Dr", "9 Hill Ln"];
316
+
317
+ // the first five start at once, the sixth a second later
318
+ const results = await Promise.all(addresses.map(geocode));
319
+ ```
320
+
321
+ | Option | Meaning |
322
+ | ------ | ------- |
323
+ | `overflow` | `"wait"` (default) keeps a call until it can start. `"drop"` drops a call that finds the window full: it resolves with `undefined` |
324
+ | `key` | each key has its own window, for example one per user |
325
+
326
+ - It limits **starts**, not how many runs go at the same time, and it does not wait for a run to end. For both limits write `queue(rateLimit(5, 1000, fn), { concurrency: 2 })`: a call keeps its slot in the queue while it waits for the window.
327
+ - A call whose task aborts while it waits leaves the line. Aborting a run that has started does not give its place in the window back: the request was sent.
328
+ - A `limit` below 1 is 1.
329
+
330
+ ## `batch`: send many calls as one
331
+
332
+ > **Level C.** Use it when work is cheaper in bulk: events for analytics, log lines, "get these ids".
333
+
334
+ **Problem.** A page sends an analytics event for every click, 30 requests a minute. The server prefers one request with all of them.
335
+
336
+ `batch(ms, fn)` opens a window of `ms` at the first call and collects the payloads that come in it. When the window closes, `fn` runs once with an array of them, in the order of the calls. The window does not move when more calls come, so a steady stream is sent every `ms`, not only when it stops.
337
+
338
+ ```js
339
+ import { batch, withPolicy } from "nanoeffects/policies";
340
+
341
+ // batch — fn gets an array of everything that came within 500 ms
342
+ const policy = batch(500, async (events) => {
343
+ await api.sendEvents(events);
344
+ });
345
+ const track = withPolicy(policy);
346
+
347
+ track({ name: "open" });
348
+ track({ name: "click" }); // one request with both events, 500 ms after the first
349
+ ```
350
+
351
+ | Option | Meaning |
352
+ | ------ | ------- |
353
+ | `maxSize` | send the batch at once when it holds this many payloads |
354
+ | `key` | each key collects its own batch, for example one per user |
355
+
356
+ - Every call resolves with what `fn` returned for its batch: the same value for all, or the same error. `batch` does not split an array result between callers.
357
+ - A call whose task aborts while the batch is collecting leaves it and resolves with `undefined`. When every call has left, nothing runs.
358
+ - `fn` gets the context of the first caller that is still waiting. If that task aborts while `fn` runs, the run is aborted and every caller of that batch resolves with `undefined`. The run is not repeated: part of a bulk call may have happened.
359
+
172
360
  ## `withPolicy`: call a policy outside a bus
173
361
 
174
362
  **Problem.** You want a debounced input handler or a "latest request wins" fetch for your own function, but the code has no bus: a DOM handler, a click callback, a function in a service. A policy needs a context on every call, so `debounce(300, search)("re")` does not work on its own.
package/docs/recipes.md CHANGED
@@ -16,6 +16,8 @@ Each task below starts with the problem, then a short solution. Where the page h
16
16
  - [A second click sends a second request](#a-second-click-sends-a-second-request)
17
17
  - [Messages must go out in order](#messages-must-go-out-in-order)
18
18
  - [A "Delete" button in a list blocks the other rows](#a-delete-button-in-a-list-blocks-the-other-rows)
19
+ - [Several components ask for the same data](#several-components-ask-for-the-same-data)
20
+ - [A form saves the same text twice](#a-form-saves-the-same-text-twice)
19
21
  - [An unreliable network and long flows](#an-unreliable-network-and-long-flows)
20
22
  - [A request fails sometimes: retry and backoff](#a-request-fails-sometimes-retry-and-backoff)
21
23
  - [A request hangs: timeout](#a-request-hangs-timeout)
@@ -23,6 +25,8 @@ Each task below starts with the problem, then a short solution. Where the page h
23
25
  - [Wait for another event inside a handler: take](#wait-for-another-event-inside-a-handler-take)
24
26
  - [Load several things at once: all](#load-several-things-at-once-all)
25
27
  - [Take the fastest answer: race](#take-the-fastest-answer-race)
28
+ - [An API allows only N requests per second](#an-api-allows-only-n-requests-per-second)
29
+ - [Many small events, one request](#many-small-events-one-request)
26
30
  - [Features and domain logic](#features-and-domain-logic)
27
31
  - [Get the result of emit](#get-the-result-of-emit)
28
32
  - [Group a feature with its handlers: modules](#group-a-feature-with-its-handlers-modules)
@@ -238,6 +242,49 @@ bus.on(
238
242
 
239
243
  More: [Policies, `key`](policies.md#key-isolate-state-by-key).
240
244
 
245
+ ### Several components ask for the same data
246
+
247
+ A page shows a user in the header, in a sidebar and in a comment, and all three ask for user `1` in the same tick. That is three identical requests. `dedupe` makes the calls for one id share a single request. Use it for reads, not for writes.
248
+
249
+ ```js
250
+ import { createBus } from "nanoeffects";
251
+ import { dedupe } from "nanoeffects/policies";
252
+
253
+ const bus = createBus();
254
+
255
+ // dedupe — a call for an id that is loading already waits for that request
256
+ bus.on(
257
+ "user/needed",
258
+ dedupe(async (id, ctx) => {
259
+ const user = await api.getUser(id, { signal: ctx.signal });
260
+ ctx.emit("user/loaded", user);
261
+ }),
262
+ );
263
+ ```
264
+
265
+ More: [Policies, `dedupe`](policies.md#dedupe-share-one-run-between-equal-calls).
266
+
267
+ ### A form saves the same text twice
268
+
269
+ A form saves on every change. A user types a letter and deletes it, and the same text goes to the server again. `distinct` skips a call whose payload equals the last one.
270
+
271
+ ```js
272
+ import { createBus } from "nanoeffects";
273
+ import { distinct } from "nanoeffects/policies";
274
+
275
+ const bus = createBus();
276
+
277
+ // distinct — a text equal to the last saved one is skipped
278
+ bus.on(
279
+ "draft/changed",
280
+ distinct(async (text, { signal }) => {
281
+ await api.saveDraft(text, { signal });
282
+ }),
283
+ );
284
+ ```
285
+
286
+ More: [Policies, `distinct`](policies.md#distinct-skip-a-repeated-payload).
287
+
241
288
  ## An unreliable network and long flows
242
289
 
243
290
  ### A request fails sometimes: retry and backoff
@@ -369,6 +416,49 @@ bus.on("user/opened", async (id, ctx) => {
369
416
 
370
417
  More: [`all`](effects.md#all), [`race`](effects.md#race).
371
418
 
419
+ ### An API allows only N requests per second
420
+
421
+ A geocoding API allows 5 requests per second. A list of 40 addresses sends 40 requests at once, and most fail with `429`. `rateLimit` starts five and holds the rest until the window slides.
422
+
423
+ ```js
424
+ import { createBus } from "nanoeffects";
425
+ import { rateLimit } from "nanoeffects/policies";
426
+
427
+ const bus = createBus();
428
+
429
+ // rateLimit — five requests may start per second, the others wait their turn
430
+ bus.on(
431
+ "address/geocode",
432
+ rateLimit(5, 1000, async (address, ctx) => {
433
+ const place = await api.geocode(address, { signal: ctx.signal });
434
+ ctx.emit("address/found", place);
435
+ }),
436
+ );
437
+ ```
438
+
439
+ More: [Policies, `rateLimit`](policies.md#ratelimit-stay-under-a-quota).
440
+
441
+ ### Many small events, one request
442
+
443
+ A page sends an analytics event on every click, 30 requests a minute. The server prefers one request with all of them. `batch` collects the events that come within a window and sends them together.
444
+
445
+ ```js
446
+ import { createBus } from "nanoeffects";
447
+ import { batch } from "nanoeffects/policies";
448
+
449
+ const bus = createBus();
450
+
451
+ // batch — everything that comes within 500 ms goes in one request
452
+ bus.on(
453
+ "analytics/event",
454
+ batch(500, async (events, { signal }) => {
455
+ await api.sendEvents(events, { signal });
456
+ }),
457
+ );
458
+ ```
459
+
460
+ More: [Policies, `batch`](policies.md#batch-send-many-calls-as-one).
461
+
372
462
  ## Features and domain logic
373
463
 
374
464
  ### Get the result of emit
@@ -7,7 +7,7 @@
7
7
  | Импорт | Что внутри |
8
8
  | ------ | ---------- |
9
9
  | `nanoeffects` | `createBus`, типы |
10
- | `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue` |
10
+ | `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue`, `dedupe`, `distinct`, `rateLimit`, `batch` |
11
11
  | `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
12
12
 
13
13
  ## Остальные импорты
@@ -6,6 +6,30 @@
6
6
 
7
7
  Начните с `latest`. Остальные политики — в таблице и разделах ниже.
8
8
 
9
+ ## На этой странице
10
+
11
+ - [`latest`: прерывание предыдущего запуска](#latest-прерывание-предыдущего-запуска)
12
+ - [Таблица политик](#таблица-политик)
13
+ - [Справочник политик](#справочник-политик)
14
+ - [`latest`](#latest)
15
+ - [`leading`](#leading)
16
+ - [`debounce`](#debounce)
17
+ - [`throttle`](#throttle)
18
+ - [`queue`](#queue)
19
+ - [`dedupe`](#dedupe)
20
+ - [`distinct`](#distinct)
21
+ - [`rateLimit`](#ratelimit)
22
+ - [`batch`](#batch)
23
+ - [`key`](#key)
24
+ - [Настройка debounce и throttle](#настройка-debounce-и-throttle)
25
+ - [`key`: разделение state по ключу](#key-разделение-state-по-ключу)
26
+ - [Очередь событий через `queue`](#очередь-событий-через-queue)
27
+ - [`dedupe`: общий запуск для одинаковых вызовов](#dedupe-общий-запуск-для-одинаковых-вызовов)
28
+ - [`distinct`: пропуск повторного payload](#distinct-пропуск-повторного-payload)
29
+ - [`rateLimit`: соблюдение квоты](#ratelimit-соблюдение-квоты)
30
+ - [`batch`: отправка нескольких вызовов одним](#batch-отправка-нескольких-вызовов-одним)
31
+ - [`withPolicy`: вызов политики вне шины](#withpolicy-вызов-политики-вне-шины)
32
+
9
33
  ## `latest`: прерывание предыдущего запуска
10
34
 
11
35
  ```js
@@ -37,6 +61,10 @@ bus.on(
37
61
  | `debounce(ms, fn, options?)` | запуск после `ms` тишины |
38
62
  | `throttle(ms, fn, options?)` | не больше одного запуска за `ms` |
39
63
  | `queue(fn, options?)` | один за другим, по порядку |
64
+ | `dedupe(fn, options?)` | вызовы с одним ключом делят идущий запуск |
65
+ | `distinct(fn, options?)` | вызов с тем же payload, что и последний, пропускается |
66
+ | `rateLimit(limit, ms, fn, options?)` | не больше `limit` запусков за `ms`, остальные ждут |
67
+ | `batch(ms, fn, options?)` | payload одного окна попадают в `fn` одним массивом |
40
68
 
41
69
  ## Справочник политик
42
70
 
@@ -82,6 +110,38 @@ bus.on(
82
110
 
83
111
  Подробнее: [Очередь событий через `queue`](#очередь-событий-через-queue).
84
112
 
113
+ ### `dedupe`
114
+
115
+ Вызовы с одним ключом, пришедшие во время запуска, ждут этот запуск и получают его результат. Ничего не кэшируется.
116
+
117
+ Сигнатура: `dedupe(fn, options?)`.
118
+
119
+ Подробнее: [`dedupe`: общий запуск для одинаковых вызовов](#dedupe-общий-запуск-для-одинаковых-вызовов).
120
+
121
+ ### `distinct`
122
+
123
+ Вызов, чей payload равен последнему, пропускается и завершается значением `undefined`.
124
+
125
+ Сигнатура: `distinct(fn, options?)`.
126
+
127
+ Подробнее: [`distinct`: пропуск повторного payload](#distinct-пропуск-повторного-payload).
128
+
129
+ ### `rateLimit`
130
+
131
+ В любом окне из `ms` начинается не больше `limit` запусков. Остальные вызовы ждут своей очереди.
132
+
133
+ Сигнатура: `rateLimit(limit, ms, fn, options?)`.
134
+
135
+ Подробнее: [`rateLimit`: соблюдение квоты](#ratelimit-соблюдение-квоты).
136
+
137
+ ### `batch`
138
+
139
+ Собирает payload, пришедшие за окно в `ms`, и вызывает `fn` один раз со всеми ними.
140
+
141
+ Сигнатура: `batch(ms, fn, options?)`.
142
+
143
+ Подробнее: [`batch`: отправка нескольких вызовов одним](#batch-отправка-нескольких-вызовов-одним).
144
+
85
145
  ### `key`
86
146
 
87
147
  Параметр каждой политики. Он разделяет state политики по признаку, например по id: вызовы с разными ключами не влияют друг на друга.
@@ -167,6 +227,134 @@ bus.emit("save", draftB); // ждёт draftA, затем выполняется
167
227
  - Когда прерывается выполняющийся вызов, его слот освобождается сразу, а результат отбрасывается.
168
228
  - Передавайте `signal` в то, что делает `fn`. `fn`, который его игнорирует, может пересечься со следующим запуском.
169
229
 
230
+ ## `dedupe`: общий запуск для одинаковых вызовов
231
+
232
+ > **Уровень B или C.** Подходит для чтения, которое несколько частей экрана запускают одновременно.
233
+
234
+ **Проблема.** Три компонента в одном такте запрашивают пользователя `1`. Без политики это три одинаковых запроса.
235
+
236
+ **Когда.** Для чтения, которое безопасно повторять: профиль, конфиг, список по id. Для записи используйте [`leading`](prevent-double-submit.md) или [`queue`](#очередь-событий-через-queue): см. правило прерывания ниже.
237
+
238
+ `dedupe` запускает `fn` для первого вызова. Вызов с тем же ключом, пришедший, пока идёт этот запуск, ждёт его и получает его результат или его ошибку. Когда запуск закончился, `dedupe` его забывает, и следующий вызов запускает `fn` заново. Это не кэш: старое значение он не вернёт никогда.
239
+
240
+ ```js
241
+ import { dedupe, withPolicy } from "nanoeffects/policies";
242
+
243
+ // dedupe — вызовы с одним id делят один запрос
244
+ const policy = dedupe((id, { signal }) => api.getUser(id, { signal }));
245
+
246
+ // withPolicy — обычная функция, поэтому пример показывает результат каждого вызова
247
+ const loadUser = withPolicy(policy);
248
+
249
+ // два вызова, один запрос: оба получают одного пользователя
250
+ const [first, second] = await Promise.all([loadUser(1), loadUser(1)]);
251
+ ```
252
+
253
+ На шине выглядит так же: `bus.on("user/load", dedupe(handler))`. Чтобы получить результат `emit`, используйте action или job ([Actions](actions.md)).
254
+
255
+ | Параметр | Значение |
256
+ | -------- | -------- |
257
+ | `key` | что делает два вызова «одинаковыми». По умолчанию сам payload, сравнивается как ключ `Map`. Объекты не совпадают, пока `key` не скажет как. `key: () => "all"` даёт один общий запуск на все вызовы |
258
+
259
+ **Прерывание:**
260
+
261
+ - Вызов, чья задача прервана, завершается значением `undefined`. Остальные продолжают.
262
+ - Запуск принадлежит задаче первого вызова. Если эта задача прерывается, пока другие ещё ждут, запуск прерывается и начинается заново со следующим вызовом, поэтому `fn` может выполниться дважды для одного запроса. Для чтения это нормально, для записи — нет.
263
+ - Передавайте `signal` в то, что делает `fn`, как для любой политики.
264
+
265
+ ## `distinct`: пропуск повторного payload
266
+
267
+ > **Уровень B.** Подходит, когда одно и то же значение приходит снова и снова, а повторять работу не нужно.
268
+
269
+ **Проблема.** Форма сохраняется при каждом изменении. Пользователь набрал букву и стёр её, и тот же текст снова уходит на сервер.
270
+
271
+ `distinct` сравнивает payload каждого вызова с payload последнего пропущенного вызова. Равный payload пропускается: `fn` не запускается, а вызов завершается значением `undefined`. Политика помнит один payload, но не результат.
272
+
273
+ ```js
274
+ import { distinct, withPolicy } from "nanoeffects/policies";
275
+
276
+ // distinct — запускается, только если текст отличается от последнего пропущенного
277
+ const policy = distinct(async (text) => {
278
+ await api.save(text);
279
+ });
280
+ const save = withPolicy(policy);
281
+
282
+ await save("hello"); // выполняется
283
+ await save("hello"); // пропущен, завершается значением undefined
284
+ await save("hello!"); // выполняется: текст изменился
285
+ ```
286
+
287
+ | Параметр | Значение |
288
+ | -------- | -------- |
289
+ | `equals(previous, next)` | говорит, одинаковы ли два payload. По умолчанию `Object.is` |
290
+ | `key` | каждый ключ помнит свой payload |
291
+
292
+ - Payload считается увиденным с момента начала его запуска, поэтому такой же payload, пришедший во время запуска, тоже пропускается.
293
+ - Он забывается, когда запуск завершился ошибкой или его задача прервана, поэтому повтор с тем же payload выполнится.
294
+ - Память ключа остаётся после запуска. В отличие от других политик, `distinct` не удаляет простаивающие ключи, поэтому давайте `key` значения из ограниченного набора (id формы, а не id запроса).
295
+
296
+ ## `rateLimit`: соблюдение квоты
297
+
298
+ > **Уровень C.** Подходит, когда API разрешает N запросов за период.
299
+
300
+ **Проблема.** API геокодирования разрешает 5 запросов в секунду. Список из 40 адресов отправляет 40 запросов сразу, и большинство падает с `429`.
301
+
302
+ `rateLimit(limit, ms, fn)` разрешает начаться не больше чем `limit` запускам в любом окне из `ms` миллисекунд. Окно скользящее: оно отсчитывается назад от каждого вызова. Вызов сверх лимита ждёт и начинается по порядку, когда самый старый старт выходит из окна.
303
+
304
+ ```js
305
+ import { rateLimit, withPolicy } from "nanoeffects/policies";
306
+
307
+ // rateLimit — за секунду могут начаться 5 запусков, остальные ждут своей очереди
308
+ const policy = rateLimit(5, 1000, (address, { signal }) => {
309
+ return api.geocode(address, { signal });
310
+ });
311
+ const geocode = withPolicy(policy);
312
+
313
+ const addresses = ["10 Main St", "22 Oak Ave", "5 Pine Rd", "8 Elm St", "3 Lake Dr", "9 Hill Ln"];
314
+
315
+ // первые пять начинаются сразу, шестой — через секунду
316
+ const results = await Promise.all(addresses.map(geocode));
317
+ ```
318
+
319
+ | Параметр | Значение |
320
+ | -------- | -------- |
321
+ | `overflow` | `"wait"` (по умолчанию) держит вызов, пока он не сможет начаться. `"drop"` отбрасывает вызов, который застал окно полным: он завершается значением `undefined` |
322
+ | `key` | у каждого ключа своё окно, например по одному на пользователя |
323
+
324
+ - Политика ограничивает **старты**, а не число одновременных запусков, и не ждёт окончания запуска. Для обоих ограничений напишите `queue(rateLimit(5, 1000, fn), { concurrency: 2 })`: вызов держит свой слот в очереди, пока ждёт окна.
325
+ - Вызов, чья задача прервана во время ожидания, выходит из очереди. Прерывание уже начавшегося запуска не возвращает его место в окне: запрос уже отправлен.
326
+ - `limit` меньше 1 считается равным 1.
327
+
328
+ ## `batch`: отправка нескольких вызовов одним
329
+
330
+ > **Уровень C.** Подходит, когда работа дешевле оптом: события для аналитики, строки лога, запрос «дай эти id».
331
+
332
+ **Проблема.** Страница отправляет событие аналитики на каждый клик, 30 запросов в минуту. Серверу удобнее один запрос со всеми.
333
+
334
+ `batch(ms, fn)` открывает окно в `ms` на первом вызове и собирает payload, пришедшие за это время. Когда окно закрывается, `fn` запускается один раз с массивом из них, в порядке вызовов. Окно не сдвигается, когда приходят новые вызовы, поэтому постоянный поток отправляется каждые `ms`, а не только когда он затих.
335
+
336
+ ```js
337
+ import { batch, withPolicy } from "nanoeffects/policies";
338
+
339
+ // batch — fn получает массив всего, что пришло за 500 мс
340
+ const policy = batch(500, async (events) => {
341
+ await api.sendEvents(events);
342
+ });
343
+ const track = withPolicy(policy);
344
+
345
+ track({ name: "open" });
346
+ track({ name: "click" }); // один запрос с обоими событиями, через 500 мс после первого
347
+ ```
348
+
349
+ | Параметр | Значение |
350
+ | -------- | -------- |
351
+ | `maxSize` | отправить пакет сразу, когда в нём набралось столько payload |
352
+ | `key` | каждый ключ собирает свой пакет, например по одному на пользователя |
353
+
354
+ - Каждый вызов завершается тем, что `fn` вернул для его пакета: одно и то же значение для всех или одна и та же ошибка. `batch` не делит результат-массив между вызовами.
355
+ - Вызов, чья задача прервана, пока пакет собирается, выходит из него и завершается значением `undefined`. Когда вышли все вызовы, ничего не запускается.
356
+ - `fn` получает контекст первого вызова, который ещё ждёт. Если эта задача прерывается, пока идёт `fn`, запуск прерывается, и все вызовы этого пакета завершаются значением `undefined`. Запуск не повторяется: часть массового вызова могла уже выполниться.
357
+
170
358
  ## `withPolicy`: вызов политики вне шины
171
359
 
172
360
  **Проблема.** Нужен обработчик ввода с debounce или запрос по принципу «побеждает последний» для вашей собственной функции, но шины в коде нет: DOM-обработчик, callback клика, функция в сервисе. Политике на каждом вызове нужен контекст, поэтому `debounce(300, search)("re")` сам по себе не работает.