@procedures/client 0.15.0 → 0.16.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 (85) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/adapters/http/server/handler.d.ts.map +1 -1
  4. package/dist/adapters/http/server/handler.js +5 -3
  5. package/dist/adapters/http/server/handler.js.map +1 -1
  6. package/dist/browser.d.ts +3 -2
  7. package/dist/browser.d.ts.map +1 -1
  8. package/dist/browser.js +1 -1
  9. package/dist/browser.js.map +1 -1
  10. package/dist/client/errors/registry.d.ts.map +1 -1
  11. package/dist/client/errors/registry.js +12 -0
  12. package/dist/client/errors/registry.js.map +1 -1
  13. package/dist/client/middleware/rate-limit.d.ts +17 -0
  14. package/dist/client/middleware/rate-limit.d.ts.map +1 -1
  15. package/dist/client/middleware/rate-limit.js +94 -39
  16. package/dist/client/middleware/rate-limit.js.map +1 -1
  17. package/dist/events/index.d.ts +1 -0
  18. package/dist/events/index.d.ts.map +1 -1
  19. package/dist/events/index.js.map +1 -1
  20. package/dist/events/publisher.d.ts +50 -5
  21. package/dist/events/publisher.d.ts.map +1 -1
  22. package/dist/events/publisher.js +99 -10
  23. package/dist/events/publisher.js.map +1 -1
  24. package/dist/events/recheck.d.ts +84 -0
  25. package/dist/events/recheck.d.ts.map +1 -0
  26. package/dist/events/recheck.js +135 -0
  27. package/dist/events/recheck.js.map +1 -0
  28. package/dist/procedures/collection/changes.d.ts +9 -8
  29. package/dist/procedures/collection/changes.d.ts.map +1 -1
  30. package/dist/procedures/collection/changes.js +11 -11
  31. package/dist/procedures/collection/changes.js.map +1 -1
  32. package/dist/procedures/collection/index.d.ts +1 -0
  33. package/dist/procedures/collection/index.d.ts.map +1 -1
  34. package/dist/procedures/collection/index.js +2 -0
  35. package/dist/procedures/collection/index.js.map +1 -1
  36. package/dist/procedures/collection/partition.d.ts +4 -23
  37. package/dist/procedures/collection/partition.d.ts.map +1 -1
  38. package/dist/procedures/collection/partition.js +2 -28
  39. package/dist/procedures/collection/partition.js.map +1 -1
  40. package/dist/procedures/collection/topic.d.ts +86 -0
  41. package/dist/procedures/collection/topic.d.ts.map +1 -0
  42. package/dist/procedures/collection/topic.js +94 -0
  43. package/dist/procedures/collection/topic.js.map +1 -0
  44. package/dist/procedures/index.d.ts +1 -1
  45. package/dist/procedures/index.d.ts.map +1 -1
  46. package/dist/procedures/index.js +1 -1
  47. package/dist/procedures/index.js.map +1 -1
  48. package/dist/protocol.d.ts +1 -1
  49. package/dist/protocol.d.ts.map +1 -1
  50. package/dist/protocol.js +1 -0
  51. package/dist/protocol.js.map +1 -1
  52. package/dist/server/host-policy.d.ts +17 -0
  53. package/dist/server/host-policy.d.ts.map +1 -1
  54. package/dist/server/host-policy.js +7 -0
  55. package/dist/server/host-policy.js.map +1 -1
  56. package/dist/server/limits.d.ts +122 -0
  57. package/dist/server/limits.d.ts.map +1 -0
  58. package/dist/server/limits.js +153 -0
  59. package/dist/server/limits.js.map +1 -0
  60. package/dist/server/procedure-server.d.ts +35 -3
  61. package/dist/server/procedure-server.d.ts.map +1 -1
  62. package/dist/server/procedure-server.js +42 -8
  63. package/dist/server/procedure-server.js.map +1 -1
  64. package/dist/server/request-auth.d.ts +5 -1
  65. package/dist/server/request-auth.d.ts.map +1 -1
  66. package/dist/server/request-auth.js +19 -2
  67. package/dist/server/request-auth.js.map +1 -1
  68. package/package.json +3 -3
  69. package/src/adapters/http/server/handler.ts +5 -3
  70. package/src/browser.ts +5 -0
  71. package/src/client/errors/registry.ts +13 -0
  72. package/src/client/middleware/rate-limit.ts +116 -41
  73. package/src/events/index.ts +1 -0
  74. package/src/events/publisher.ts +121 -12
  75. package/src/events/recheck.ts +182 -0
  76. package/src/procedures/collection/changes.ts +12 -11
  77. package/src/procedures/collection/index.ts +2 -0
  78. package/src/procedures/collection/partition.ts +5 -27
  79. package/src/procedures/collection/topic.ts +115 -0
  80. package/src/procedures/index.ts +1 -0
  81. package/src/protocol.ts +3 -1
  82. package/src/server/host-policy.ts +22 -0
  83. package/src/server/limits.ts +254 -0
  84. package/src/server/procedure-server.ts +59 -9
  85. package/src/server/request-auth.ts +19 -2
@@ -5,13 +5,20 @@
5
5
  * Prevents overwhelming services with too many requests.
6
6
  *
7
7
  * Works with both client and server!
8
+ *
9
+ * The bucket is a keyed limiter of `@mark1russell7/service/limiter` (CLI-40): the same primitive
10
+ * as the limits of the procedures server. Its policy is a token bucket of `maxRequests` tokens
11
+ * that refills `maxRequests` tokens in each `window`, as before.
8
12
  */
9
13
 
14
+ import { valueIn, type Quantity } from "@mark1russell7/quantity";
15
+ import { keyedLimiter, memoryStore, type Decision, type LimitRule } from "@mark1russell7/service/limiter";
10
16
  import type { ClientMiddleware, ClientRunner, ClientContext, TypedClientMiddleware } from "../types.js";
11
17
  import type { RateLimitContext } from "./contexts.js";
12
18
  import { abortedItem } from "./items.js";
13
19
  import { optionalMs, type Duration } from "../../duration.js";
14
20
  import { TIMER_DEFAULTS } from "../../timers.js";
21
+ import type { Cancel, Clock } from "../../clock.js";
15
22
 
16
23
  /**
17
24
  * Rate limiting options.
@@ -19,12 +26,18 @@ import { TIMER_DEFAULTS } from "../../timers.js";
19
26
  export interface RateLimitOptions {
20
27
  /**
21
28
  * Maximum number of requests per time window.
29
+ *
30
+ * Kept for compatibility: a value below 1 never lets a request in, and a value above 1 that is
31
+ * not whole gives a bucket of that size. An infinite value limits nothing.
22
32
  * @default 100
23
33
  */
24
34
  maxRequests?: number;
25
35
 
26
36
  /**
27
37
  * Time window in milliseconds.
38
+ *
39
+ * Kept for compatibility: a window of 0 or less fills the bucket again at each request, so a
40
+ * `maxRequests` of 1 or more limits nothing.
28
41
  * @default 60000 (1 minute)
29
42
  */
30
43
  window?: Duration;
@@ -52,6 +65,13 @@ export interface RateLimitOptions {
52
65
  * Callback when rate limit is exceeded.
53
66
  */
54
67
  onRateLimitExceeded?: (stats: RateLimitStats) => void;
68
+
69
+ /**
70
+ * The clock of the limiter: the time of the bucket and the timer of the queue (CLI-40). The
71
+ * default reads `Date.now()` and starts its timer with `setTimeout`, as before, so a queued
72
+ * request keeps a process alive. A test gives a fake clock.
73
+ */
74
+ clock?: Clock | undefined;
55
75
  }
56
76
 
57
77
  /**
@@ -86,6 +106,74 @@ interface QueuedRequest {
86
106
  timestamp: number;
87
107
  }
88
108
 
109
+ /** The name of the keyed limiter of a middleware. Each middleware has its own store, so the name is the same for each one. */
110
+ const LIMITER = "client.rateLimit";
111
+ /** The one key of the bucket of a middleware. */
112
+ const KEY = "client";
113
+
114
+ /**
115
+ * The clock of a limiter without the option `clock`: `Date.now()` from the start of the
116
+ * middleware, and `setTimeout`. Thus the bucket reads the same time as before, and a queued
117
+ * request keeps a Node process alive until it runs.
118
+ */
119
+ function wallClock(): Clock {
120
+ const origin = Date.now();
121
+ return {
122
+ now: () => Date.now() - origin,
123
+ after(delay: number, run: () => void): Cancel {
124
+ const timer = setTimeout(run, Math.max(0, delay));
125
+ return () => clearTimeout(timer);
126
+ },
127
+ };
128
+ }
129
+
130
+ /** This function gives a wait in milliseconds as a quantity. */
131
+ const msOf = (value: number): Quantity => ({ value, unit: "ms" });
132
+
133
+ /**
134
+ * This function gives the take of the bucket of the options. The take decides at once, without a
135
+ * promise.
136
+ *
137
+ * - A whole `maxRequests` of 1 or more and a `window` above 0: a keyed limiter of the service on a
138
+ * memory store (CLI-40). Its token bucket gives `maxRequests` takes at once, and one more take
139
+ * each `window / maxRequests`.
140
+ * - Kept for compatibility: a `maxRequests` above 1 that is not whole gives the same store with a
141
+ * bucket of that size. The keyed limiter refuses such a spec, so the rule is given in numbers.
142
+ * - Kept for compatibility: a `window` of 0 or less fills the bucket again at each request. Thus a
143
+ * `maxRequests` of 1 or more limits nothing.
144
+ * - Kept for compatibility: a `maxRequests` below 1 never lets a request in. A queued request
145
+ * waits without an end, and the timer of the queue keeps a process alive, as before.
146
+ * - Kept for compatibility: an infinite `maxRequests` limits nothing.
147
+ */
148
+ function bucketOf(maxRequests: number, window: number, clock: Clock): () => Decision {
149
+ if (window <= 0 && maxRequests >= 1) {
150
+ const remaining = Math.floor(maxRequests - 1);
151
+ return () => ({ allowed: true, remaining });
152
+ }
153
+ if (!(maxRequests >= 1)) {
154
+ // The wait of the queue before CLI-40: the time until one token, or 1 ms when it has no value
155
+ const wait = ((1 - maxRequests) * window) / maxRequests;
156
+ const waitMs = Number.isFinite(wait) && wait > 1 ? wait : 1;
157
+ return () => ({ allowed: false, retryAfter: msOf(waitMs), cause: "limit" });
158
+ }
159
+ if (maxRequests === Number.POSITIVE_INFINITY) return () => ({ allowed: true, remaining: Number.POSITIVE_INFINITY });
160
+ const store = memoryStore(clock);
161
+ const rule: LimitRule = Number.isInteger(maxRequests)
162
+ ? keyedLimiter(
163
+ LIMITER,
164
+ {
165
+ key: KEY,
166
+ policy: { type: "token-bucket", rate: { value: maxRequests / window, unit: "count/ms" }, burst: { value: maxRequests, unit: "count" } },
167
+ store: { kind: "local" },
168
+ evict: { maxKeys: { value: 1, unit: "count" } },
169
+ },
170
+ store,
171
+ ).rule
172
+ : { type: "token-bucket", interval: window / maxRequests, burst: maxRequests };
173
+ // The memory store decides in one step without a promise
174
+ return () => store.take({ limiter: LIMITER, key: KEY, rule, maxKeys: 1 }) as Decision;
175
+ }
176
+
89
177
  /**
90
178
  * Create rate limiting middleware using token bucket algorithm.
91
179
  *
@@ -121,34 +209,22 @@ export function createRateLimitMiddleware(
121
209
  onRateLimitExceeded,
122
210
  } = options;
123
211
  const window = optionalMs(options.window, "window") ?? TIMER_DEFAULTS.rateLimit.windowMs;
124
-
125
- // Token bucket state
126
- let tokens = maxRequests;
127
- let lastRefill = Date.now();
212
+ const clock = options.clock ?? wallClock();
213
+ const takeOfBucket = bucketOf(maxRequests, window, clock);
128
214
  let totalRequests = 0;
129
215
  let rejectedRequests = 0;
216
+ /** The whole tokens after the last decision: `remaining` of a take, or 0 after a refusal. */
217
+ let tokensAvailable = maxRequests;
130
218
 
131
219
  // Queue for "queue" strategy
132
220
  const queue: QueuedRequest[] = [];
133
- let drainTimer: ReturnType<typeof setTimeout> | undefined;
221
+ let drainTimer: Cancel | undefined;
134
222
 
135
- /**
136
- * Refill tokens based on elapsed time.
137
- */
138
- function refillTokens(): void {
139
- const now = Date.now();
140
- const elapsed = now - lastRefill;
141
-
142
- if (elapsed >= window) {
143
- // Full refill
144
- tokens = maxRequests;
145
- lastRefill = now;
146
- } else {
147
- // Partial refill (tokens per millisecond * elapsed time)
148
- const tokensToAdd = (maxRequests / window) * elapsed;
149
- tokens = Math.min(maxRequests, tokens + tokensToAdd);
150
- lastRefill = now;
151
- }
223
+ /** One take of the bucket, at once, so the requests keep their order, as before. */
224
+ function take(): Decision {
225
+ const decision = takeOfBucket();
226
+ tokensAvailable = decision.allowed ? decision.remaining : 0;
227
+ return decision;
152
228
  }
153
229
 
154
230
  /**
@@ -156,7 +232,7 @@ export function createRateLimitMiddleware(
156
232
  */
157
233
  function getStats(): RateLimitStats {
158
234
  return {
159
- tokensAvailable: Math.floor(tokens),
235
+ tokensAvailable,
160
236
  maxTokens: maxRequests,
161
237
  queueSize: queue.length,
162
238
  totalRequests,
@@ -170,11 +246,8 @@ export function createRateLimitMiddleware(
170
246
  * an arrival could take the token of a queued request (deep dive TRN-12).
171
247
  */
172
248
  async function acquireToken(signal?: AbortSignal): Promise<boolean> {
173
- refillTokens();
174
-
175
- if (queue.length === 0 && tokens >= 1) {
249
+ if (queue.length === 0 && take().allowed) {
176
250
  // Token available
177
- tokens -= 1;
178
251
  return true;
179
252
  }
180
253
 
@@ -207,7 +280,7 @@ export function createRateLimitMiddleware(
207
280
  signal?.removeEventListener("abort", onAbort);
208
281
  resolve(true);
209
282
  },
210
- timestamp: Date.now(),
283
+ timestamp: clock.now(),
211
284
  };
212
285
  // A queued request whose caller aborts leaves the queue (before, it kept its place)
213
286
  const onAbort = (): void => {
@@ -223,23 +296,25 @@ export function createRateLimitMiddleware(
223
296
 
224
297
  /**
225
298
  * Give tokens to the queued requests, in order, and wait for the next token while the queue
226
- * is not empty. The timer keeps the process alive while requests wait: before, an unref'd
227
- * timer let the process exit with queued requests that never ran (deep dive TRN-12).
299
+ * is not empty. The wait is the `retryAfter` of the refusal of the bucket. The timer of the
300
+ * default clock keeps the process alive while requests wait: before, an unref'd timer let the
301
+ * process exit with queued requests that never ran (deep dive TRN-12).
228
302
  */
229
303
  function scheduleDrain(): void {
230
304
  if (drainTimer !== undefined) return;
231
- refillTokens();
232
- while (queue.length > 0 && tokens >= 1) {
233
- tokens -= 1;
305
+ while (queue.length > 0) {
306
+ const decision = take();
307
+ if (!decision.allowed) {
308
+ // The time until the next whole token
309
+ const wait = Math.max(1, Math.ceil(valueIn(decision.retryAfter, "ms")));
310
+ drainTimer = clock.after(wait, () => {
311
+ drainTimer = undefined;
312
+ scheduleDrain();
313
+ });
314
+ return;
315
+ }
234
316
  queue.shift()!.resolve();
235
317
  }
236
- if (queue.length === 0) return;
237
- // The time until the next whole token
238
- const wait = Math.max(1, Math.ceil(((1 - tokens) * window) / maxRequests));
239
- drainTimer = setTimeout(() => {
240
- drainTimer = undefined;
241
- scheduleDrain();
242
- }, wait);
243
318
  }
244
319
 
245
320
  const middleware = <TReq, TRes>(next: ClientRunner<TReq, TRes>): ClientRunner<TReq, TRes> => {
@@ -253,7 +328,7 @@ export function createRateLimitMiddleware(
253
328
  }
254
329
  // The state for the other middlewares (RateLimitContext)
255
330
  context.message.metadata["rateLimit"] = {
256
- tokensAvailable: Math.floor(tokens),
331
+ tokensAvailable,
257
332
  maxTokens: maxRequests,
258
333
  queueSize: queue.length,
259
334
  };
@@ -59,6 +59,7 @@ export {
59
59
  // The remote events: a peer subscribes to the topics of the bus of another peer (EVENTS.md)
60
60
  export { EventPublisher, topicMatcher, MAX_SUBSCRIPTION_TOPICS, MAX_TOPIC_LENGTH } from "./publisher.js";
61
61
  export type { EventPublisherOptions, TopicRule, TopicCall } from "./publisher.js";
62
+ export type { RightsChange, SubscriptionRecheck } from "./recheck.js";
62
63
  export { EVENTS_SUBSCRIBE_PATH, SubscriptionError, remoteEvents, subscribeRemote } from "./remote.js";
63
64
  export type {
64
65
  EventItem,
@@ -18,6 +18,8 @@
18
18
  *
19
19
  * - A value with rows (`withRows()`) reaches each subscriber through the output filter of its host
20
20
  * (`ctx.admit`): each row passes the filter, and the subscriber gets only the admitted rows.
21
+ * - An open subscription gets the same checks again at a change of the rights (`rightsChanged()`,
22
+ * `recheck.rightsChanges`) and after `recheck.maxAge` (roadmap CLI-44). A refusal ends it.
21
23
  *
22
24
  * The delivery is at most once. The queue of a subscription holds at most `buffer` events. When
23
25
  * it is full, the publisher drops the oldest event, and the next event tells the number of lost
@@ -36,6 +38,8 @@ import type { EventBus, Unsubscribe } from "./types.js";
36
38
  import { getGlobalEventBus } from "./bus.js";
37
39
  import { EVENTS_SUBSCRIBE_PATH, type EventItem, type SubscribeInput } from "./remote.js";
38
40
  import { admittedValue, rowsOf } from "./rows.js";
41
+ import { realClock, type Clock } from "../clock.js";
42
+ import { SubscriptionRechecks, type RightsChange, type SubscriptionRecheck } from "./recheck.js";
39
43
 
40
44
  /** The number of topics that one subscription can name. */
41
45
  export const MAX_SUBSCRIPTION_TOPICS = 64;
@@ -73,7 +77,8 @@ export interface EventPublisherOptions {
73
77
  allow?: ((topic: string, call: TopicCall) => boolean) | undefined;
74
78
  /**
75
79
  * The number of events that one subscription holds for a slow subscriber. When the queue is
76
- * full, the publisher drops the oldest event.
80
+ * full, the publisher drops the oldest event. The events that wait for a re-check (roadmap
81
+ * CLI-44) are in the same queue, so a slow check holds at most this number of events too.
77
82
  * @default 1024
78
83
  */
79
84
  buffer?: number | undefined;
@@ -86,9 +91,26 @@ export interface EventPublisherOptions {
86
91
  * collection that the publisher listens to: the collection of `collectionTopicTarget(topic,
87
92
  * repository)` with the repository of the host. A function that reads the first part of the
88
93
  * topic alone checks `collection:a` for the dotted collection `a.b`.
89
- * @default the read of the collection of `collectionTopicTarget()`, with the repository of the call
94
+ * @default `collectionTopicReads(topic, repository)`: the read of the collection of `collectionTopicTarget()`, with the repository of the call
90
95
  */
91
96
  reads?: ((topic: string) => readonly string[]) | undefined;
97
+ /**
98
+ * The clock of the re-check after `recheck.maxAge` (roadmap CLI-44). A test gives the
99
+ * `fakeClock` of `@mark1russell7/service/testing`. A `ProcedureServer` gives its own clock to the
100
+ * publisher that it makes from its option `events`.
101
+ * @default the clock of the platform
102
+ */
103
+ clock?: Clock | undefined;
104
+ /**
105
+ * The re-check of the admission of each open subscription (roadmap CLI-44). A right can change
106
+ * after the start of a subscription. The publisher then examines the subscription again with the
107
+ * same checks: the topic rule, the reads, `allow` and the partition. A refusal ends it, for
108
+ * example with `FORBIDDEN`. `maxAge` starts a check after a time on the clock, and
109
+ * `rightsChanges` starts a check at each change from a source of the app. `rightsChanged()` also
110
+ * starts a check.
111
+ * @default no check after the start, other than at a call of `rightsChanged()`
112
+ */
113
+ recheck?: SubscriptionRecheck | undefined;
92
114
  }
93
115
 
94
116
  /** A procedure that reads resources, for the admission of a topic under a capability. */
@@ -167,6 +189,10 @@ class TopicSubscription implements AsyncIterableIterator<EventItem> {
167
189
  private closed = false;
168
190
  private failure: unknown;
169
191
  private readonly unsubscribes: Unsubscribe[];
192
+ /** The re-check of the admission that runs (roadmap CLI-44). The events wait for it. */
193
+ private recheck: Promise<void> | undefined;
194
+ /** The functions that run when the subscription ends */
195
+ private readonly endListeners: Array<() => void> = [];
170
196
 
171
197
  constructor(
172
198
  bus: EventBus,
@@ -201,7 +227,7 @@ class TopicSubscription implements AsyncIterableIterator<EventItem> {
201
227
  if (this.closed) return;
202
228
  const event = { type: "event" as const, topic, data, seq: ++this.seq };
203
229
  const waiter = this.waiter;
204
- if (waiter && this.queue.length === 0) {
230
+ if (waiter && this.queue.length === 0 && !this.recheck) {
205
231
  this.waiter = undefined;
206
232
  waiter.resolve(this.deliver(event));
207
233
  return;
@@ -239,6 +265,8 @@ class TopicSubscription implements AsyncIterableIterator<EventItem> {
239
265
  this.confirmation = undefined;
240
266
  return Promise.resolve({ value: confirmation, done: false });
241
267
  }
268
+ // The events wait for a re-check of the admission (roadmap CLI-44)
269
+ if (this.recheck) return this.recheck.then(() => this.next());
242
270
  const event = this.queue.shift();
243
271
  if (event) return this.deliver(event);
244
272
  if (this.failure !== undefined) {
@@ -295,9 +323,51 @@ class TopicSubscription implements AsyncIterableIterator<EventItem> {
295
323
  for (const unsubscribe of this.unsubscribes) unsubscribe();
296
324
  this.signal?.removeEventListener("abort", this.onAbort);
297
325
  this.onClose(this);
326
+ for (const listener of this.endListeners.splice(0)) listener();
327
+ }
328
+
329
+ /**
330
+ * This function holds the events until the check ends (roadmap CLI-44). A check that passes gives
331
+ * the held events to the reader. A check that fails ends the subscription with its error, and the
332
+ * held events go. The promise ends when the check ends, and it does not reject.
333
+ */
334
+ hold(check: Promise<void>): Promise<void> {
335
+ if (this.closed) return check.then(ignore, ignore);
336
+ const running: Promise<void> = check.then(
337
+ () => {
338
+ if (this.recheck === running) this.recheck = undefined;
339
+ this.flush();
340
+ },
341
+ (error: unknown) => {
342
+ if (this.recheck === running) this.recheck = undefined;
343
+ this.queue.length = 0;
344
+ this.fail(error);
345
+ },
346
+ );
347
+ this.recheck = running;
348
+ return running;
349
+ }
350
+
351
+ /** This function runs the listener when the subscription ends, or at once after its end. */
352
+ onEnd(listener: () => void): void {
353
+ if (this.closed) listener();
354
+ else this.endListeners.push(listener);
355
+ }
356
+
357
+ /** This function gives the first held event to a reader that waits, after a check passes. */
358
+ private flush(): void {
359
+ const waiter = this.waiter;
360
+ const event = this.queue[0];
361
+ if (!waiter || !event || this.recheck) return;
362
+ this.waiter = undefined;
363
+ this.queue.shift();
364
+ waiter.resolve(this.deliver(event));
298
365
  }
299
366
  }
300
367
 
368
+ /** This function does nothing: the end of a check of a closed subscription. */
369
+ function ignore(): void {}
370
+
301
371
  /**
302
372
  * The publisher of the remote events: it serves `events.subscribe` from a bus, under a topic
303
373
  * rule. `ProcedureServer` makes one from its option `events`.
@@ -321,8 +391,11 @@ export class EventPublisher {
321
391
  private readonly defaultReads: boolean;
322
392
  private readonly buffer: number;
323
393
  private readonly open = new Set<TopicSubscription>();
394
+ /** The re-checks of the admission of the open subscriptions (roadmap CLI-44) */
395
+ private readonly rechecks: SubscriptionRechecks;
324
396
 
325
397
  constructor(options: EventPublisherOptions) {
398
+ this.rechecks = new SubscriptionRechecks(options.recheck ?? {}, options.clock ?? realClock);
326
399
  this.bus = options.bus ?? getGlobalEventBus();
327
400
  this.known = topicMatcher(options.topics);
328
401
  this.allow = options.allow;
@@ -360,6 +433,18 @@ export class EventPublisher {
360
433
  for (const subscription of [...this.open]) subscription.fail(stoppedError());
361
434
  }
362
435
 
436
+ /**
437
+ * This function tells the publisher that rights changed (roadmap CLI-44). Each open subscription
438
+ * that the change touches gets the checks of its start again: the topic rule, the reads, `allow`
439
+ * and the partition. Its events wait for the check. A refusal ends the subscription with its
440
+ * error, for example `FORBIDDEN`. The promise ends when each check ends, and it does not reject.
441
+ *
442
+ * @param change - The subscriptions that the change touches. Without it, each open subscription gets a check.
443
+ */
444
+ rightsChanged(change?: RightsChange): Promise<void> {
445
+ return this.rechecks.rightsChanged(change);
446
+ }
447
+
363
448
  /**
364
449
  * The topic of the bus for a topic of a subscriber (roadmap CLI-17). A topic of a partitioned
365
450
  * collection names the partition that the subscriber asks for, as the input of a procedure does:
@@ -374,22 +459,20 @@ export class EventPublisher {
374
459
  }
375
460
 
376
461
  /**
377
- * The resources that a subscriber of a topic reads. The default reads the collection of the
378
- * topic with the rule of the listener (`collectionTopicTarget`), so the read check and the
379
- * listener name the same collection.
462
+ * The resources that a subscriber of a topic reads. The default (`collectionTopicReads`) reads
463
+ * the collection of the topic with the rule of the listener (`collectionTopicTarget`) and the
464
+ * repository of the call, so the read check and the listener name the same collection.
380
465
  */
381
466
  private readsOf(topic: string, ctx: ProcedureContext): readonly string[] {
382
467
  if (!this.defaultReads) return this.reads(topic);
383
- const target = collectionTopicTarget(topic, ctx.repository);
384
- return target ? [`collection:${target.collection}`] : [];
468
+ return collectionTopicReads(topic, ctx.repository);
385
469
  }
386
470
 
387
471
  /**
388
- * The stream of one subscription. The publisher examines each topic before it adds a listener:
389
- * the topic rule, the reads, `allow`, and the partition. A call with one bad topic makes no subscription.
472
+ * The checks of the topics of a call: the topic rule, the reads of the capability and `allow`.
473
+ * A subscription passes them at its start and at each re-check (roadmap CLI-44).
390
474
  */
391
- private async subscribe(topics: readonly string[], ctx: ProcedureContext): Promise<TopicSubscription> {
392
- const unique = [...new Set(topics)];
475
+ private admitTopics(unique: readonly string[], ctx: ProcedureContext): void {
393
476
  for (const topic of unique) {
394
477
  if (!this.known(topic)) throw new TopicNotFoundError(`The host has no topic "${topic}"`);
395
478
  }
@@ -408,10 +491,36 @@ export class EventPublisher {
408
491
  }
409
492
  }
410
493
  }
494
+ }
495
+
496
+ /**
497
+ * The admission of an open subscription again (roadmap CLI-44): the checks of its start, and the
498
+ * partition of each topic. A refusal of the resolver throws its error. A topic whose partition
499
+ * changed throws `FORBIDDEN` (reason `partition-not-allowed`), because the listener has the old
500
+ * partition.
501
+ */
502
+ private async readmit(routes: ReadonlyArray<readonly [string, string]>, ctx: ProcedureContext): Promise<void> {
503
+ this.admitTopics(routes.map(([topic]) => topic), ctx);
504
+ for (const [topic, busTopic] of routes) {
505
+ if ((await this.busTopic(topic, ctx)) !== busTopic) {
506
+ throw new InvocationError("FORBIDDEN", `The partition of the topic "${topic}" changed, so the subscription ends`, ctx.path, "partition-not-allowed");
507
+ }
508
+ }
509
+ }
510
+
511
+ /**
512
+ * The stream of one subscription. The publisher examines each topic before it adds a listener:
513
+ * the topic rule, the reads, `allow`, and the partition. A call with one bad topic makes no subscription.
514
+ */
515
+ private async subscribe(topics: readonly string[], ctx: ProcedureContext): Promise<TopicSubscription> {
516
+ const unique = [...new Set(topics)];
517
+ this.admitTopics(unique, ctx);
411
518
  const routes: Array<readonly [string, string]> = [];
412
519
  for (const topic of unique) routes.push([topic, await this.busTopic(topic, ctx)]);
413
520
  const subscription = new TopicSubscription(this.bus, routes, this.buffer, ctx.signal, (closed) => this.open.delete(closed), ctx.admit, ctx.invocation);
414
521
  this.open.add(subscription);
522
+ // The same checks run again at a change of the rights and after the max age (roadmap CLI-44)
523
+ this.rechecks.watch(subscription, ctx.principal, () => this.readmit(routes, ctx));
415
524
  return subscription.closeIfAborted();
416
525
  }
417
526
  }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The re-check of the admission of the open subscriptions (roadmap CLI-44).
3
+ *
4
+ * At the start of a subscription, a publisher examines the rights of the subscriber. It examines
5
+ * the topic rule, the reads of the capability, `allow` and the partition. A right can change
6
+ * later, for example when the app removes a user from a group. Thus the publisher examines each
7
+ * open subscription again:
8
+ *
9
+ * - At a change of the rights. The host uses `rightsChanged()` of the publisher, or the source of
10
+ * the option `recheck.rightsChanges` gives the change to its listener.
11
+ * - After the time `recheck.maxAge` on the clock of the publisher, from the last check.
12
+ *
13
+ * The check is the same function as at the start. While it operates, the events of the
14
+ * subscription wait. A refusal ends the subscription with its error, for example `FORBIDDEN`, and
15
+ * the events that wait go. Without a call of `rightsChanged()` and without the option, nothing
16
+ * changes.
17
+ */
18
+
19
+ import type { Cancel, Clock } from "../clock.js";
20
+ import { toMs, type Duration } from "../duration.js";
21
+ import type { Principal } from "../server/types.js";
22
+
23
+ /**
24
+ * The subscriptions that a change of the rights touches: the id of a principal, or a test of the
25
+ * principal of a subscription. A subscription without a principal (a call from this process) has
26
+ * no id, so only a test or a change of each subscription touches it.
27
+ */
28
+ export type RightsChange = string | ((principal: Principal | undefined) => boolean);
29
+
30
+ /** The options of the re-check of the open subscriptions of a publisher (roadmap CLI-44). */
31
+ export interface SubscriptionRecheck {
32
+ /**
33
+ * The longest time from one check of a subscription to the next check, for example `minutes(1)`.
34
+ * The time is on the clock of the publisher. Without it, only a change of the rights starts a
35
+ * check.
36
+ */
37
+ readonly maxAge?: Duration | undefined;
38
+ /**
39
+ * The source of the changes of the rights of the app. While the publisher has an open
40
+ * subscription, the source has one listener of the publisher. The source gives the function that
41
+ * removes the listener. Each call of the listener is a call of `rightsChanged()`: without a
42
+ * change, each subscription gets a check.
43
+ */
44
+ readonly rightsChanges?: ((listener: (change?: RightsChange) => void) => () => void) | undefined;
45
+ }
46
+
47
+ /** A subscription that the re-checks can hold and watch. */
48
+ export interface RecheckedSubscription {
49
+ /**
50
+ * This function holds the events of the subscription until the check ends. A refusal ends the
51
+ * subscription with its error. The promise ends with the check, and it does not reject.
52
+ */
53
+ hold(check: Promise<void>): Promise<void>;
54
+ /** This function runs the listener when the subscription ends. */
55
+ onEnd(listener: () => void): void;
56
+ }
57
+
58
+ /** One subscription that the re-checks watch. */
59
+ interface Watch {
60
+ readonly principal: Principal | undefined;
61
+ /** The timer of the max age, or undefined. */
62
+ timer: Cancel | undefined;
63
+ /** This function starts a check at once. The promise ends when the check ends. */
64
+ readonly recheck: () => Promise<void>;
65
+ }
66
+
67
+ /** True when the change touches a subscription of the principal. A test that throws touches it. */
68
+ function touches(change: RightsChange | undefined, principal: Principal | undefined): boolean {
69
+ if (change === undefined) return true;
70
+ if (typeof change === "string") return principal?.id === change;
71
+ try {
72
+ return change(principal);
73
+ } catch {
74
+ return true;
75
+ }
76
+ }
77
+
78
+ /** The promise of a check. A check that throws at once gives a rejected promise. */
79
+ function started(check: () => Promise<void>): Promise<void> {
80
+ return new Promise<void>((resolve) => resolve(check()));
81
+ }
82
+
83
+ /**
84
+ * The re-checks of the open subscriptions of one publisher (roadmap CLI-44). The publisher gives
85
+ * each new subscription to `watch()`, with the check of its admission.
86
+ */
87
+ export class SubscriptionRechecks {
88
+ private readonly watches = new Set<Watch>();
89
+ private readonly maxAgeMs: number | undefined;
90
+ private readonly source: SubscriptionRecheck["rightsChanges"];
91
+ /** The function that removes the listener of the source, while a subscription is open */
92
+ private stopListening: (() => void) | undefined;
93
+
94
+ constructor(
95
+ options: SubscriptionRecheck,
96
+ private readonly clock: Clock,
97
+ ) {
98
+ const maxAgeMs = options.maxAge === undefined ? undefined : toMs(options.maxAge, "recheck.maxAge");
99
+ if (maxAgeMs !== undefined && !(maxAgeMs > 0)) {
100
+ throw new TypeError(`recheck.maxAge must be more than 0 ms, got ${maxAgeMs} ms`);
101
+ }
102
+ this.maxAgeMs = maxAgeMs;
103
+ this.source = options.rightsChanges;
104
+ }
105
+
106
+ /** The number of the subscriptions that the re-checks watch. */
107
+ get size(): number {
108
+ return this.watches.size;
109
+ }
110
+
111
+ /**
112
+ * This function watches a subscription until it ends. A change of the rights of its principal
113
+ * and its max age start the check. The check must throw the refusal.
114
+ */
115
+ watch(subscription: RecheckedSubscription, principal: Principal | undefined, check: () => Promise<void>): void {
116
+ const watch: Watch = {
117
+ principal,
118
+ timer: undefined,
119
+ recheck: () => {
120
+ this.stopTimer(watch);
121
+ return subscription.hold(started(check)).then(() => {
122
+ // A check that passes gives the admission a new age
123
+ if (this.watches.has(watch)) this.startTimer(watch);
124
+ });
125
+ },
126
+ };
127
+ this.watches.add(watch);
128
+ subscription.onEnd(() => this.unwatch(watch));
129
+ if (!this.watches.has(watch)) return;
130
+ this.startTimer(watch);
131
+ if (this.source && !this.stopListening) {
132
+ try {
133
+ this.stopListening = this.source((change) => void this.rightsChanged(change));
134
+ } catch (error) {
135
+ // A source that cannot listen cannot tell a revocation: the subscription ends
136
+ void subscription.hold(Promise.reject(error));
137
+ }
138
+ }
139
+ }
140
+
141
+ /**
142
+ * This function starts a check of each watched subscription that the change touches. The
143
+ * promise ends when each check ends.
144
+ */
145
+ rightsChanged(change?: RightsChange): Promise<void> {
146
+ const checks: Array<Promise<void>> = [];
147
+ for (const watch of [...this.watches]) {
148
+ if (touches(change, watch.principal)) checks.push(watch.recheck());
149
+ }
150
+ return Promise.all(checks).then(() => undefined);
151
+ }
152
+
153
+ /** This function stops the watch of a subscription that ended. */
154
+ private unwatch(watch: Watch): void {
155
+ this.stopTimer(watch);
156
+ if (!this.watches.delete(watch) || this.watches.size > 0) return;
157
+ // The last subscription ended: the publisher does not listen to the source
158
+ const stop = this.stopListening;
159
+ this.stopListening = undefined;
160
+ stop?.();
161
+ }
162
+
163
+ /** This function starts the timer of the max age of a subscription. */
164
+ private startTimer(watch: Watch): void {
165
+ if (this.maxAgeMs === undefined) return;
166
+ this.stopTimer(watch);
167
+ const timer = this.clock.after(this.maxAgeMs, () => {
168
+ if (watch.timer === timer) watch.timer = undefined;
169
+ void watch.recheck();
170
+ });
171
+ // A check does not keep a Node process alive
172
+ timer.unref?.();
173
+ watch.timer = timer;
174
+ }
175
+
176
+ /** This function stops the timer of the max age of a subscription. */
177
+ private stopTimer(watch: Watch): void {
178
+ const timer = watch.timer;
179
+ watch.timer = undefined;
180
+ timer?.();
181
+ }
182
+ }