@procedures/client 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 (120) hide show
  1. package/CHANGELOG.md +67 -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 +17 -3
  5. package/dist/adapters/http/server/handler.js.map +1 -1
  6. package/dist/adapters/port/transport.d.ts.map +1 -1
  7. package/dist/adapters/port/transport.js +2 -0
  8. package/dist/adapters/port/transport.js.map +1 -1
  9. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  10. package/dist/adapters/websocket/server/transport.js +2 -0
  11. package/dist/adapters/websocket/server/transport.js.map +1 -1
  12. package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
  13. package/dist/adapters/websocket/shared/rpc.js +14 -5
  14. package/dist/adapters/websocket/shared/rpc.js.map +1 -1
  15. package/dist/browser.d.ts +6 -4
  16. package/dist/browser.d.ts.map +1 -1
  17. package/dist/browser.js +4 -1
  18. package/dist/browser.js.map +1 -1
  19. package/dist/client/errors/index.d.ts +1 -1
  20. package/dist/client/errors/index.d.ts.map +1 -1
  21. package/dist/client/errors/index.js +1 -1
  22. package/dist/client/errors/index.js.map +1 -1
  23. package/dist/client/errors/registry.d.ts +10 -0
  24. package/dist/client/errors/registry.d.ts.map +1 -1
  25. package/dist/client/errors/registry.js +12 -0
  26. package/dist/client/errors/registry.js.map +1 -1
  27. package/dist/client/index.d.ts +1 -1
  28. package/dist/client/index.d.ts.map +1 -1
  29. package/dist/client/index.js +1 -1
  30. package/dist/client/index.js.map +1 -1
  31. package/dist/events/bus.d.ts.map +1 -1
  32. package/dist/events/bus.js +5 -1
  33. package/dist/events/bus.js.map +1 -1
  34. package/dist/events/index.d.ts +1 -1
  35. package/dist/events/index.d.ts.map +1 -1
  36. package/dist/events/publisher.d.ts +18 -2
  37. package/dist/events/publisher.d.ts.map +1 -1
  38. package/dist/events/publisher.js +142 -25
  39. package/dist/events/publisher.js.map +1 -1
  40. package/dist/events/recheck.d.ts +27 -5
  41. package/dist/events/recheck.d.ts.map +1 -1
  42. package/dist/events/recheck.js +11 -10
  43. package/dist/events/recheck.js.map +1 -1
  44. package/dist/procedures/collection/admission.d.ts +26 -7
  45. package/dist/procedures/collection/admission.d.ts.map +1 -1
  46. package/dist/procedures/collection/admission.js +39 -7
  47. package/dist/procedures/collection/admission.js.map +1 -1
  48. package/dist/procedures/collection/changes.d.ts +28 -4
  49. package/dist/procedures/collection/changes.d.ts.map +1 -1
  50. package/dist/procedures/collection/changes.js +50 -6
  51. package/dist/procedures/collection/changes.js.map +1 -1
  52. package/dist/procedures/collection/hint-filter.d.ts +143 -0
  53. package/dist/procedures/collection/hint-filter.d.ts.map +1 -0
  54. package/dist/procedures/collection/hint-filter.js +311 -0
  55. package/dist/procedures/collection/hint-filter.js.map +1 -0
  56. package/dist/procedures/collection/index.d.ts +2 -0
  57. package/dist/procedures/collection/index.d.ts.map +1 -1
  58. package/dist/procedures/collection/index.js +2 -0
  59. package/dist/procedures/collection/index.js.map +1 -1
  60. package/dist/procedures/collection/procedures.d.ts +2 -2
  61. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  62. package/dist/procedures/collection/procedures.js +6 -4
  63. package/dist/procedures/collection/procedures.js.map +1 -1
  64. package/dist/procedures/index.d.ts +2 -2
  65. package/dist/procedures/index.d.ts.map +1 -1
  66. package/dist/procedures/index.js +3 -1
  67. package/dist/procedures/index.js.map +1 -1
  68. package/dist/server/error-policy.d.ts +96 -0
  69. package/dist/server/error-policy.d.ts.map +1 -0
  70. package/dist/server/error-policy.js +196 -0
  71. package/dist/server/error-policy.js.map +1 -0
  72. package/dist/server/error-response.d.ts +30 -0
  73. package/dist/server/error-response.d.ts.map +1 -0
  74. package/dist/server/error-response.js +90 -0
  75. package/dist/server/error-response.js.map +1 -0
  76. package/dist/server/index.d.ts +2 -0
  77. package/dist/server/index.d.ts.map +1 -1
  78. package/dist/server/index.js +1 -0
  79. package/dist/server/index.js.map +1 -1
  80. package/dist/server/last-resort.d.ts +17 -0
  81. package/dist/server/last-resort.d.ts.map +1 -0
  82. package/dist/server/last-resort.js +19 -0
  83. package/dist/server/last-resort.js.map +1 -0
  84. package/dist/server/procedure-server.d.ts +3 -11
  85. package/dist/server/procedure-server.d.ts.map +1 -1
  86. package/dist/server/procedure-server.js +7 -42
  87. package/dist/server/procedure-server.js.map +1 -1
  88. package/dist/server/server.d.ts +43 -1
  89. package/dist/server/server.d.ts.map +1 -1
  90. package/dist/server/server.js +39 -2
  91. package/dist/server/server.js.map +1 -1
  92. package/dist/server/types.d.ts +12 -0
  93. package/dist/server/types.d.ts.map +1 -1
  94. package/dist/server/types.js.map +1 -1
  95. package/package.json +1 -1
  96. package/src/adapters/http/server/handler.ts +18 -3
  97. package/src/adapters/port/transport.ts +2 -0
  98. package/src/adapters/websocket/server/transport.ts +2 -0
  99. package/src/adapters/websocket/shared/rpc.ts +14 -5
  100. package/src/browser.ts +11 -0
  101. package/src/client/errors/index.ts +1 -0
  102. package/src/client/errors/registry.ts +13 -0
  103. package/src/client/index.ts +1 -0
  104. package/src/events/bus.ts +6 -1
  105. package/src/events/index.ts +1 -1
  106. package/src/events/publisher.ts +150 -26
  107. package/src/events/recheck.ts +38 -13
  108. package/src/procedures/collection/admission.ts +50 -9
  109. package/src/procedures/collection/changes.ts +68 -7
  110. package/src/procedures/collection/hint-filter.ts +376 -0
  111. package/src/procedures/collection/index.ts +3 -0
  112. package/src/procedures/collection/procedures.ts +8 -6
  113. package/src/procedures/index.ts +5 -0
  114. package/src/server/error-policy.ts +263 -0
  115. package/src/server/error-response.ts +91 -0
  116. package/src/server/index.ts +2 -0
  117. package/src/server/last-resort.ts +23 -0
  118. package/src/server/procedure-server.ts +8 -41
  119. package/src/server/server.ts +73 -3
  120. package/src/server/types.ts +13 -0
@@ -18,6 +18,7 @@ import { withoutInternalKeys } from "../../metadata.js";
18
18
  import { StreamSequence, gapOf, itemMetadata } from "../../sequence.js";
19
19
  import { DELIVERY_METADATA_KEY } from "../../../client/middleware/items.js";
20
20
  import { errorCodeOf, foldedErrorCode, retryableOf } from "../../../protocol.js";
21
+ import { lastResortError } from "../../../server/last-resort.js";
21
22
 
22
23
  /**
23
24
  * A queue of response items that one reader takes in order: push, then end or fail. The reader
@@ -382,8 +383,10 @@ export class IncomingCalls {
382
383
  const controller = new AbortController();
383
384
  this.inflight.set(frame.id, controller);
384
385
  let credit: StreamCredit | undefined;
386
+ let request: ServerRequest | undefined;
385
387
  try {
386
- const response = await this.options.handler.handle(this.options.requestOf(frame, controller.signal));
388
+ request = this.options.requestOf(frame, controller.signal);
389
+ const response = await this.options.handler.handle(request);
387
390
  if (response.stream) {
388
391
  credit = typeof frame.credit === "number" && frame.credit > 0 ? new StreamCredit(frame.credit) : undefined;
389
392
  if (credit) this.credits.set(frame.id, credit);
@@ -402,7 +405,12 @@ export class IncomingCalls {
402
405
  this.send(out);
403
406
  }
404
407
  } catch (error) {
405
- this.sendError(frame.id, error instanceof Error ? error.message : "Internal server error", "INTERNAL_ERROR");
408
+ // The last resort: the error policy of the server applies here too (CLI-80)
409
+ const raw = { code: "INTERNAL_ERROR", message: error instanceof Error ? error.message : "Internal server error", retryable: false };
410
+ const exposed = lastResortError(this.options.handler, error, request, raw);
411
+ // The wait of a map result (retryAfterMs) goes out as on the usual error path (CLI-80)
412
+ const wait = exposed.retryAfterMs;
413
+ this.sendError(frame.id, exposed.message, exposed.code, exposed.retryable === true, typeof wait === "number" && Number.isFinite(wait) && wait >= 0 ? { "retry-after": wait } : undefined);
406
414
  } finally {
407
415
  // Delete only this request's entries (deep dive TRN-9)
408
416
  if (this.inflight.get(frame.id) === controller) {
@@ -463,12 +471,13 @@ export class IncomingCalls {
463
471
  if (this.options.isOpen()) this.options.send(frame);
464
472
  }
465
473
 
466
- private sendError(id: string, message: string, code: string): void {
474
+ private sendError(id: string, message: string, code: string, retryable = false, metadata?: Record<string, unknown>): void {
467
475
  this.send({
468
476
  id,
469
477
  type: "error",
470
- status: { type: "error", code, message, retryable: false },
471
- error: { code, message, retryable: false },
478
+ ...(metadata ? { metadata } : {}),
479
+ status: { type: "error", code, message, retryable },
480
+ error: { code, message, retryable },
472
481
  });
473
482
  }
474
483
  }
package/src/browser.ts CHANGED
@@ -64,6 +64,7 @@ export {
64
64
  ERROR_REGISTRY,
65
65
  getErrorMetadata,
66
66
  isKnownError,
67
+ isKnownErrorCode,
67
68
  createError,
68
69
  createErrorFromHTTPStatus,
69
70
  createErrorFromException,
@@ -76,6 +77,9 @@ export {
76
77
  // ============================================================================
77
78
  export { Server } from "./server/server.js";
78
79
  export type { ServerOptions } from "./server/server.js";
80
+ // The error policy of a server: what an error tells the caller (roadmap CLI-80)
81
+ export type { ServerErrorPolicy, ExposedError, ErrorCall } from "./server/error-policy.js";
82
+ export { exposeError, lastResortError, HIDDEN_ERROR_MESSAGE, DEFAULT_KEPT_METADATA } from "./server/error-policy.js";
79
83
  export { ProcedureServer, createProcedureServer, STREAM_METADATA_KEY, isUnboundedStream, tokenMatches } from "./server/procedure-server.js";
80
84
  // The credentials of a network request (roadmap R18), and the parts that a request hook reads (CLI-2a)
81
85
  export { credentialsOf, bearerOf, withoutSecretHeaders, cookiesOf, requestCredentials, LOCAL_CREDENTIALS, isLocalCredentials } from "./server/request-auth.js";
@@ -144,6 +148,9 @@ export {
144
148
  // The rules of the rows of a collection (roadmap CLI-41)
145
149
  admittedStorage,
146
150
  admitsRow,
151
+ // The images of the rows of a change event, for the row filter of each subscriber (roadmap CLI-78)
152
+ withHintImages,
153
+ hintImagesOf,
147
154
  // The query language of the collection storages (roadmap CLI-14, CLI-34)
148
155
  DEFAULT_FIND_LIMIT,
149
156
  DEFAULT_QUERY_OPERATORS,
@@ -333,6 +340,8 @@ export type {
333
340
  CollectionChangeEventsOptions,
334
341
  CollectionAdmission,
335
342
  CollectionRow,
343
+ HintImages,
344
+ HintRowState,
336
345
  CollectionWrite,
337
346
  CollectionWriteGuard,
338
347
  CollectionPartitions,
@@ -586,6 +595,8 @@ export type {
586
595
  TopicRule,
587
596
  TopicCall,
588
597
  RightsChange,
598
+ RightsScope,
599
+ RightsScopes,
589
600
  SubscriptionRecheck,
590
601
  EventItem,
591
602
  RemoteEvent,
@@ -46,6 +46,7 @@ export {
46
46
  ERROR_REGISTRY,
47
47
  getErrorMetadata,
48
48
  isKnownError,
49
+ isKnownErrorCode,
49
50
  getErrorsByCategory,
50
51
  getRetryableErrors,
51
52
  } from "./registry.js";
@@ -643,6 +643,19 @@ export function isKnownError(code: string): boolean {
643
643
  return code in ERROR_REGISTRY;
644
644
  }
645
645
 
646
+ /**
647
+ * True when a value is a known error code of the client: a code of the wire protocol
648
+ * (`ERROR_CODES`) or another code of `ERROR_REGISTRY`, for example `CONFLICT` or `BAD_REQUEST`
649
+ * (roadmap CLI-80). Only a string can be a known code. A code of the platform (`ECONNREFUSED`,
650
+ * `ENOENT`) and a number (a code of MongoDB) are not known codes.
651
+ *
652
+ * The error policy `expose: "coded"` of a server uses this check: an error with a known code is a
653
+ * deliberate refusal, so its code and its message go to the caller.
654
+ */
655
+ export function isKnownErrorCode(code: unknown): code is string {
656
+ return typeof code === "string" && Object.prototype.hasOwnProperty.call(ERROR_REGISTRY, code);
657
+ }
658
+
646
659
  /**
647
660
  * Get all error codes by category.
648
661
  */
@@ -15,6 +15,7 @@ export {
15
15
  ERROR_REGISTRY,
16
16
  getErrorMetadata,
17
17
  isKnownError,
18
+ isKnownErrorCode,
18
19
  createError,
19
20
  createErrorFromHTTPStatus,
20
21
  createErrorFromException,
package/src/events/bus.ts CHANGED
@@ -444,6 +444,9 @@ export function resetGlobalEventBus(): void {
444
444
  globalBus = undefined;
445
445
  }
446
446
 
447
+ /** The key of the bus under a view of a bus. */
448
+ const BASE_BUS = Symbol.for("procedures.baseBus");
449
+
447
450
  /**
448
451
  * A view of a bus whose `stream()` and `once()` use a signal when the caller gives none. The
449
452
  * context of a procedure gets this view: when the invocation aborts, the handler's waits on
@@ -455,7 +458,7 @@ export function resetGlobalEventBus(): void {
455
458
  */
456
459
  export function withEventBusSignal(bus: EventBus, signal: AbortSignal): EventBus {
457
460
  const waitOptions = (options?: EventWaitOptions): EventWaitOptions => ({ signal: options?.signal ?? signal });
458
- return {
461
+ const view: EventBus = {
459
462
  emit: (channel, data) => bus.emit(channel, data),
460
463
  on: (channel, handler, options) => bus.on(channel, handler, options),
461
464
  once: (channel, options) => bus.once(channel, waitOptions(options)),
@@ -466,6 +469,8 @@ export function withEventBusSignal(bus: EventBus, signal: AbortSignal): EventBus
466
469
  clear: (channel) => bus.clear(channel),
467
470
  clearAll: () => bus.clearAll(),
468
471
  };
472
+ // The bus under the view: the marks of the row filters of the events belong to it (roadmap CLI-78)
473
+ return Object.defineProperty(view, BASE_BUS, { value: (bus as { [BASE_BUS]?: EventBus })[BASE_BUS] ?? bus, enumerable: false });
469
474
  }
470
475
 
471
476
  // =============================================================================
@@ -59,7 +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
+ export type { RightsChange, RightsScope, RightsScopes, SubscriptionRecheck } from "./recheck.js";
63
63
  export { EVENTS_SUBSCRIBE_PATH, SubscriptionError, remoteEvents, subscribeRemote } from "./remote.js";
64
64
  export type {
65
65
  EventItem,
@@ -20,6 +20,12 @@
20
20
  * (`ctx.admit`): each row passes the filter, and the subscriber gets only the admitted rows.
21
21
  * - An open subscription gets the same checks again at a change of the rights (`rightsChanged()`,
22
22
  * `recheck.rightsChanges`) and after `recheck.maxAge` (roadmap CLI-44). A refusal ends it.
23
+ * - The change events of a collection with a rule of the rows (`admission.admit`) reach each
24
+ * subscriber through a row filter (roadmap CLI-78, `hint-filter.ts`). The subscriber gets only the
25
+ * ids of the rows that it can read. A re-check at a change of the rights gives such a subscriber a
26
+ * `reset` hint, so it reads its rows again.
27
+ * - The `seq` of an event counts the events that the subscriber gets. An event that the row filter
28
+ * takes out gets no number, so the subscriber sees no gap.
23
29
  *
24
30
  * The delivery is at most once. The queue of a subscription holds at most `buffer` events. When
25
31
  * it is full, the publisher drops the oldest event, and the next event tells the number of lost
@@ -39,7 +45,8 @@ import { getGlobalEventBus } from "./bus.js";
39
45
  import { EVENTS_SUBSCRIBE_PATH, type EventItem, type SubscribeInput } from "./remote.js";
40
46
  import { admittedValue, rowsOf } from "./rows.js";
41
47
  import { realClock, type Clock } from "../clock.js";
42
- import { SubscriptionRechecks, type RightsChange, type SubscriptionRecheck } from "./recheck.js";
48
+ import { SubscriptionRechecks, type RecheckCause, type RightsChange, type RightsScope, type RightsScopes, type SubscriptionRecheck } from "./recheck.js";
49
+ import { DROPPED, RowHintFilter, collectionInvocation, collectionRulesOf, markFilteredTopic, type HintFilterMode } from "../procedures/collection/hint-filter.js";
43
50
 
44
51
  /** The number of topics that one subscription can name. */
45
52
  export const MAX_SUBSCRIPTION_TOPICS = 64;
@@ -172,7 +179,45 @@ const eventItemSchema = z.discriminatedUnion("type", [
172
179
  ]);
173
180
 
174
181
  /** A topic of a subscriber, and the topic of the bus that it listens to. */
175
- type Route = readonly [topic: string, busTopic: string];
182
+ interface Route {
183
+ readonly topic: string;
184
+ readonly busTopic: string;
185
+ /** The collection of a topic of a collection, and its resolved partition (roadmap CLI-78). */
186
+ readonly collection?: string | undefined;
187
+ readonly partition?: string | undefined;
188
+ /** The partition that the topic names, before the resolver: the input of the rules has it. */
189
+ readonly requested?: string | undefined;
190
+ }
191
+
192
+ /** True when a change of the rights with this scope touches the rows of the route (roadmap CLI-78). */
193
+ function inScope(route: Route, scope: RightsScopes | undefined): boolean {
194
+ if (scope === undefined) return true;
195
+ const scopes: readonly RightsScope[] = Array.isArray(scope) ? scope : [scope as RightsScope];
196
+ return scopes.some(({ collection, partition }) => matchesName(collection, route.collection) && matchesName(partition, route.partition));
197
+ }
198
+
199
+ /** True when a name of a route matches a field of a scope. A test that throws matches. */
200
+ function matchesName(field: RightsScope["collection"], name: string | undefined): boolean {
201
+ if (field === undefined) return true;
202
+ if (name === undefined) return false;
203
+ if (typeof field === "string") return field === name;
204
+ try {
205
+ return field(name) === true;
206
+ } catch {
207
+ return true;
208
+ }
209
+ }
210
+
211
+ /** An event of the bus that waits in the queue of a subscription. It gets its `seq` when the subscriber gets it. */
212
+ interface QueuedEvent {
213
+ readonly topic: string;
214
+ readonly data: unknown;
215
+ }
216
+
217
+ /** The `reset` hint that a re-check gives to a subscriber with a row filter (roadmap CLI-78). */
218
+ function resetHint(): unknown {
219
+ return { changed: [], deleted: [], reset: true };
220
+ }
176
221
 
177
222
  /**
178
223
  * One subscription: the listeners on the bus and the queue of its events. It is the async
@@ -182,7 +227,7 @@ type Route = readonly [topic: string, busTopic: string];
182
227
  */
183
228
  class TopicSubscription implements AsyncIterableIterator<EventItem> {
184
229
  private confirmation: EventItem | undefined;
185
- private readonly queue: Array<Extract<EventItem, { type: "event" }>> = [];
230
+ private readonly queue: QueuedEvent[] = [];
186
231
  private seq = 0;
187
232
  private dropped = 0;
188
233
  private waiter:
@@ -207,10 +252,12 @@ class TopicSubscription implements AsyncIterableIterator<EventItem> {
207
252
  private readonly admit: ((item: unknown) => Promise<boolean>) | undefined,
208
253
  /** The invocation of the subscription: the rule of the rows of a value gets it (roadmap CLI-41) */
209
254
  private readonly invocation?: Invocation | undefined,
255
+ /** The row filter of each topic of a collection, by the topic of the subscriber (roadmap CLI-78) */
256
+ private readonly filters?: ReadonlyMap<string, RowHintFilter> | undefined,
210
257
  ) {
211
- this.confirmation = { type: "subscribed", topics: routes.map(([topic]) => topic) };
258
+ this.confirmation = { type: "subscribed", topics: routes.map(({ topic }) => topic) };
212
259
  // Before, clear() removed the listeners, and the subscriber saw no end (EVENTS.md)
213
- this.unsubscribes = routes.map(([topic, busTopic]) =>
260
+ this.unsubscribes = routes.map(({ topic, busTopic }) =>
214
261
  bus.on(busTopic, (data) => this.push(topic, data), { onClear: () => this.fail(clearedError(topic), { keepQueue: true }) }),
215
262
  );
216
263
  signal?.addEventListener("abort", this.onAbort, { once: true });
@@ -227,7 +274,7 @@ class TopicSubscription implements AsyncIterableIterator<EventItem> {
227
274
  /** This function takes one event of the bus. It never waits. */
228
275
  private push(topic: string, data: unknown): void {
229
276
  if (this.closed) return;
230
- const event = { type: "event" as const, topic, data, seq: ++this.seq };
277
+ const event: QueuedEvent = { topic, data };
231
278
  const waiter = this.waiter;
232
279
  if (waiter && this.queue.length === 0 && !this.recheck) {
233
280
  this.waiter = undefined;
@@ -243,22 +290,44 @@ class TopicSubscription implements AsyncIterableIterator<EventItem> {
243
290
  }
244
291
 
245
292
  /**
246
- * The item of an event for the subscriber. The rows of its data pass the output filter of the
247
- * host for this subscriber and the rule of the rows, and the subscriber gets only the admitted rows.
293
+ * This function gives a `reset` hint to the subscriber on a topic (roadmap CLI-78): its rights
294
+ * changed, so it reads its rows again. The hint goes after the events of the queue.
248
295
  */
249
- private deliver(event: Extract<EventItem, { type: "event" }>): Promise<IteratorResult<EventItem>> {
250
- const item = this.withDropped(event) as Extract<EventItem, { type: "event" }>;
251
- const rows = rowsOf(item.data);
252
- if (!rows || (!this.admit && !rows.admit)) return Promise.resolve({ value: item, done: false });
253
- return admittedValue(item.data, this.admit, this.invocation).then((data): IteratorResult<EventItem> => ({ value: { ...item, data }, done: false }));
296
+ reset(topic: string): void {
297
+ this.filters?.get(topic)?.forget();
298
+ this.push(topic, resetHint());
299
+ }
300
+
301
+ /**
302
+ * The item of an event for the subscriber. The ids of a change event pass the row filter of the
303
+ * topic (roadmap CLI-78). The rows of its data pass the output filter of the host for this
304
+ * subscriber and the rule of the rows. The subscriber gets only the admitted rows. An event
305
+ * that the row filter takes out goes, and the subscriber gets the next event.
306
+ */
307
+ private deliver(event: QueuedEvent): Promise<IteratorResult<EventItem>> {
308
+ const filtered = this.filters?.get(event.topic)?.apply(event.data);
309
+ if (!filtered) return this.item(event.topic, event.data);
310
+ return filtered.then(
311
+ (data) => (data === DROPPED ? this.next() : this.item(event.topic, data)),
312
+ // A filter that fails: the subscriber reads the changes itself
313
+ () => this.item(event.topic, { changed: [], deleted: [] }),
314
+ );
254
315
  }
255
316
 
256
- /** The event with the number of events that the publisher dropped before it. */
257
- private withDropped(event: Extract<EventItem, { type: "event" }>): EventItem {
258
- if (this.dropped === 0) return event;
317
+ /**
318
+ * The item of the data of an event. It gets the next `seq`, and the number of events that the
319
+ * publisher dropped before it. A dropped event keeps its number, so the subscriber sees the gap.
320
+ */
321
+ private item(topic: string, data: unknown): Promise<IteratorResult<EventItem>> {
259
322
  const dropped = this.dropped;
260
323
  this.dropped = 0;
261
- return { ...event, dropped };
324
+ this.seq += dropped;
325
+ const item: Extract<EventItem, { type: "event" }> = { type: "event", topic, data, seq: ++this.seq, ...(dropped > 0 && { dropped }) };
326
+ const rows = rowsOf(data);
327
+ if (!rows || (!this.admit && !rows.admit)) return Promise.resolve({ value: item, done: false });
328
+ // The rule of the rows of a topic of a collection gets the invocation of that topic (roadmap CLI-78)
329
+ const invocation = this.filters?.get(topic)?.invocation ?? this.invocation;
330
+ return admittedValue(data, this.admit, invocation).then((admitted): IteratorResult<EventItem> => ({ value: { ...item, data: admitted }, done: false }));
262
331
  }
263
332
 
264
333
  next(): Promise<IteratorResult<EventItem>> {
@@ -441,10 +510,15 @@ export class EventPublisher {
441
510
  * and the partition. Its events wait for the check. A refusal ends the subscription with its
442
511
  * error, for example `FORBIDDEN`. The promise ends when each check ends, and it does not reject.
443
512
  *
513
+ * The change can also have a scope (roadmap CLI-78): the collections or the partitions whose rows
514
+ * it touches. Then only the topics of the scope with a row filter get a `reset` hint and a new
515
+ * `admitsAll`. Each touched subscription still gets the checks of its start.
516
+ *
444
517
  * @param change - The subscriptions that the change touches. Without it, each open subscription gets a check.
518
+ * @param scope - The rows that the change touches. Without it, the change touches each topic.
445
519
  */
446
- rightsChanged(change?: RightsChange): Promise<void> {
447
- return this.rechecks.rightsChanged(change);
520
+ rightsChanged(change?: RightsChange, scope?: RightsScopes): Promise<void> {
521
+ return this.rechecks.rightsChanged(change, scope);
448
522
  }
449
523
 
450
524
  /**
@@ -459,9 +533,9 @@ export class EventPublisher {
459
533
  */
460
534
  private async route(topic: string, ctx: ProcedureContext): Promise<Route> {
461
535
  const target = collectionTopicTarget(topic, ctx.repository);
462
- if (!target) return [topic, topic];
536
+ if (!target) return { topic, busTopic: topic };
463
537
  const partition = await partitionOfCall(ctx, target.collection, target.partition);
464
- return [topic, collectionChangeTopic(target.collection, partition)];
538
+ return { topic, busTopic: collectionChangeTopic(target.collection, partition), collection: target.collection, partition, requested: target.partition };
465
539
  }
466
540
 
467
541
  /**
@@ -506,14 +580,30 @@ export class EventPublisher {
506
580
  * because the listener has the old partition.
507
581
  */
508
582
  private async readmit(routes: ReadonlyArray<Route>, ctx: ProcedureContext): Promise<void> {
509
- this.admitTopics(routes.map(([topic]) => topic), ctx);
510
- for (const [topic, busTopic] of routes) {
511
- if ((await this.route(topic, ctx))[1] !== busTopic) {
583
+ this.admitTopics(routes.map(({ topic }) => topic), ctx);
584
+ for (const { topic, busTopic } of routes) {
585
+ if ((await this.route(topic, ctx)).busTopic !== busTopic) {
512
586
  throw new InvocationError("FORBIDDEN", `The partition of the topic "${topic}" changed, so the subscription ends`, ctx.path, "partition-not-allowed");
513
587
  }
514
588
  }
515
589
  }
516
590
 
591
+ /**
592
+ * The row filters of the topics of the collections of a subscription (roadmap CLI-78). Each one
593
+ * gets the rules of its collection and the result of `admitsAll`.
594
+ */
595
+ private async filtersOf(routes: ReadonlyArray<Route>, ctx: ProcedureContext): Promise<Map<string, RowHintFilter>> {
596
+ const filters = new Map<string, RowHintFilter>();
597
+ for (const { topic, collection, partition, requested } of routes) {
598
+ if (collection === undefined) continue;
599
+ // The rules get an invocation of the collection and its partition, not the call of `events.subscribe`
600
+ const filter = new RowHintFilter(() => collectionRulesOf(ctx, collection), collectionInvocation(ctx.invocation, ctx, collection, requested), partition);
601
+ await filter.refresh();
602
+ filters.set(topic, filter);
603
+ }
604
+ return filters;
605
+ }
606
+
517
607
  /**
518
608
  * The stream of one subscription. The publisher examines each topic before it adds a listener:
519
609
  * the topic rule, the reads, `allow`, and the partition. A call with one bad topic makes no subscription.
@@ -523,10 +613,44 @@ export class EventPublisher {
523
613
  this.admitTopics(unique, ctx);
524
614
  const routes: Route[] = [];
525
615
  for (const topic of unique) routes.push(await this.route(topic, ctx));
526
- const subscription = new TopicSubscription(this.bus, routes, this.buffer, ctx.signal, (closed) => this.open.delete(closed), ctx.admit, ctx.invocation);
616
+ const filters = await this.filtersOf(routes, ctx);
617
+ const subscription = new TopicSubscription(this.bus, routes, this.buffer, ctx.signal, (closed) => this.open.delete(closed), ctx.admit, ctx.invocation, filters);
527
618
  this.open.add(subscription);
619
+ // The marks of the topics with a row filter: the writes of these topics read the rows before them (roadmap CLI-78)
620
+ const marks = new Map<string, () => void>();
621
+ let ended = false;
622
+ const mark = (route: Route, mode: HintFilterMode): void => {
623
+ if (ended) return;
624
+ const marked = marks.get(route.topic);
625
+ if (mode === "pass") {
626
+ marked?.();
627
+ marks.delete(route.topic);
628
+ } else if (!marked) {
629
+ marks.set(route.topic, markFilteredTopic(this.bus, route.busTopic));
630
+ }
631
+ };
632
+ for (const route of routes) {
633
+ const filter = filters.get(route.topic);
634
+ if (filter) mark(route, filter.mode);
635
+ }
636
+ subscription.onEnd(() => {
637
+ ended = true;
638
+ for (const unmark of marks.values()) unmark();
639
+ marks.clear();
640
+ });
528
641
  // The same checks run again at a change of the rights and after the max age (roadmap CLI-44)
529
- this.rechecks.watch(subscription, ctx.principal, () => this.readmit(routes, ctx));
642
+ this.rechecks.watch(subscription, ctx.principal, async (cause: RecheckCause, scope?: RightsScopes) => {
643
+ await this.readmit(routes, ctx);
644
+ // The rights can change the rows that the subscriber reads: a `reset` hint makes it read them again (roadmap CLI-78)
645
+ for (const route of routes) {
646
+ const filter = filters.get(route.topic);
647
+ if (!filter || !inScope(route, scope)) continue;
648
+ const before = filter.mode;
649
+ const after = await filter.refresh();
650
+ mark(route, after);
651
+ if (after !== before || (cause === "rights" && after === "filter")) subscription.reset(route.topic);
652
+ }
653
+ });
530
654
  return subscription.closeIfAborted();
531
655
  }
532
656
  }
@@ -27,6 +27,24 @@ import type { Principal } from "../server/types.js";
27
27
  */
28
28
  export type RightsChange = string | ((principal: Principal | undefined) => boolean);
29
29
 
30
+ /**
31
+ * The rows that a change of the rights touches (roadmap CLI-78): the rows of the collections and
32
+ * of the partitions of the scope. The partition is the partition after the resolver. Without a
33
+ * collection, the scope touches each collection. Without a partition, it touches each partition.
34
+ *
35
+ * - A string matches one name.
36
+ * - A test matches each name that it admits, for example each partition of a tenant. A test that
37
+ * throws matches: a `reset` hint too many is safe, and a missing one is not.
38
+ * - A partition matches only a topic with a partition.
39
+ */
40
+ export interface RightsScope {
41
+ readonly collection?: string | ((collection: string) => boolean) | undefined;
42
+ readonly partition?: string | ((partition: string) => boolean) | undefined;
43
+ }
44
+
45
+ /** One scope or a list of scopes of a change of the rights. A list touches the rows of each scope. */
46
+ export type RightsScopes = RightsScope | readonly RightsScope[];
47
+
30
48
  /** The options of the re-check of the open subscriptions of a publisher (roadmap CLI-44). */
31
49
  export interface SubscriptionRecheck {
32
50
  /**
@@ -41,9 +59,12 @@ export interface SubscriptionRecheck {
41
59
  * removes the listener. Each call of the listener is a call of `rightsChanged()`: without a
42
60
  * change, each subscription gets a check.
43
61
  */
44
- readonly rightsChanges?: ((listener: (change?: RightsChange) => void) => () => void) | undefined;
62
+ readonly rightsChanges?: ((listener: (change?: RightsChange, scope?: RightsScopes) => void) => () => void) | undefined;
45
63
  }
46
64
 
65
+ /** The cause of a re-check: a change of the rights, or the max age. */
66
+ export type RecheckCause = "rights" | "age";
67
+
47
68
  /** A subscription that the re-checks can hold and watch. */
48
69
  export interface RecheckedSubscription {
49
70
  /**
@@ -61,7 +82,7 @@ interface Watch {
61
82
  /** The timer of the max age, or undefined. */
62
83
  timer: Cancel | undefined;
63
84
  /** This function starts a check at once. The promise ends when the check ends. */
64
- readonly recheck: () => Promise<void>;
85
+ readonly recheck: (cause: RecheckCause, scope?: RightsScopes) => Promise<void>;
65
86
  }
66
87
 
67
88
  /** True when the change touches a subscription of the principal. A test that throws touches it. */
@@ -76,10 +97,13 @@ function touches(change: RightsChange | undefined, principal: Principal | undefi
76
97
  }
77
98
 
78
99
  /** 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()));
100
+ function started(check: RecheckCheck, cause: RecheckCause, scope: RightsScopes | undefined): Promise<void> {
101
+ return new Promise<void>((resolve) => resolve(check(cause, scope)));
81
102
  }
82
103
 
104
+ /** The check of a subscription. A check of a change of the rights can get the scope of the change. */
105
+ type RecheckCheck = (cause: RecheckCause, scope?: RightsScopes) => Promise<void>;
106
+
83
107
  /**
84
108
  * The re-checks of the open subscriptions of one publisher (roadmap CLI-44). The publisher gives
85
109
  * each new subscription to `watch()`, with the check of its admission.
@@ -110,15 +134,16 @@ export class SubscriptionRechecks {
110
134
 
111
135
  /**
112
136
  * 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.
137
+ * and its max age start the check. The check gets the cause and the scope of the change. It must
138
+ * throw the refusal.
114
139
  */
115
- watch(subscription: RecheckedSubscription, principal: Principal | undefined, check: () => Promise<void>): void {
140
+ watch(subscription: RecheckedSubscription, principal: Principal | undefined, check: RecheckCheck): void {
116
141
  const watch: Watch = {
117
142
  principal,
118
143
  timer: undefined,
119
- recheck: () => {
144
+ recheck: (cause, scope) => {
120
145
  this.stopTimer(watch);
121
- return subscription.hold(started(check)).then(() => {
146
+ return subscription.hold(started(check, cause, scope)).then(() => {
122
147
  // A check that passes gives the admission a new age
123
148
  if (this.watches.has(watch)) this.startTimer(watch);
124
149
  });
@@ -130,7 +155,7 @@ export class SubscriptionRechecks {
130
155
  this.startTimer(watch);
131
156
  if (this.source && !this.stopListening) {
132
157
  try {
133
- this.stopListening = this.source((change) => void this.rightsChanged(change));
158
+ this.stopListening = this.source((change, scope) => void this.rightsChanged(change, scope));
134
159
  } catch (error) {
135
160
  // A source that cannot listen cannot tell a revocation: the subscription ends
136
161
  void subscription.hold(Promise.reject(error));
@@ -140,12 +165,12 @@ export class SubscriptionRechecks {
140
165
 
141
166
  /**
142
167
  * This function starts a check of each watched subscription that the change touches. The
143
- * promise ends when each check ends.
168
+ * promise ends when each check ends. Each check gets the scope of the change.
144
169
  */
145
- rightsChanged(change?: RightsChange): Promise<void> {
170
+ rightsChanged(change?: RightsChange, scope?: RightsScopes): Promise<void> {
146
171
  const checks: Array<Promise<void>> = [];
147
172
  for (const watch of [...this.watches]) {
148
- if (touches(change, watch.principal)) checks.push(watch.recheck());
173
+ if (touches(change, watch.principal)) checks.push(watch.recheck("rights", scope));
149
174
  }
150
175
  return Promise.all(checks).then(() => undefined);
151
176
  }
@@ -166,7 +191,7 @@ export class SubscriptionRechecks {
166
191
  this.stopTimer(watch);
167
192
  const timer = this.clock.after(this.maxAgeMs, () => {
168
193
  if (watch.timer === timer) watch.timer = undefined;
169
- void watch.recheck();
194
+ void watch.recheck("age");
170
195
  });
171
196
  // A check does not keep a Node process alive
172
197
  timer.unref?.();