@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.
- package/CHANGELOG.md +67 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/server/handler.d.ts.map +1 -1
- package/dist/adapters/http/server/handler.js +17 -3
- package/dist/adapters/http/server/handler.js.map +1 -1
- package/dist/adapters/port/transport.d.ts.map +1 -1
- package/dist/adapters/port/transport.js +2 -0
- package/dist/adapters/port/transport.js.map +1 -1
- package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
- package/dist/adapters/websocket/server/transport.js +2 -0
- package/dist/adapters/websocket/server/transport.js.map +1 -1
- package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
- package/dist/adapters/websocket/shared/rpc.js +14 -5
- package/dist/adapters/websocket/shared/rpc.js.map +1 -1
- package/dist/browser.d.ts +6 -4
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +4 -1
- package/dist/browser.js.map +1 -1
- package/dist/client/errors/index.d.ts +1 -1
- package/dist/client/errors/index.d.ts.map +1 -1
- package/dist/client/errors/index.js +1 -1
- package/dist/client/errors/index.js.map +1 -1
- package/dist/client/errors/registry.d.ts +10 -0
- package/dist/client/errors/registry.d.ts.map +1 -1
- package/dist/client/errors/registry.js +12 -0
- package/dist/client/errors/registry.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/events/bus.d.ts.map +1 -1
- package/dist/events/bus.js +5 -1
- package/dist/events/bus.js.map +1 -1
- package/dist/events/index.d.ts +1 -1
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/publisher.d.ts +18 -2
- package/dist/events/publisher.d.ts.map +1 -1
- package/dist/events/publisher.js +142 -25
- package/dist/events/publisher.js.map +1 -1
- package/dist/events/recheck.d.ts +27 -5
- package/dist/events/recheck.d.ts.map +1 -1
- package/dist/events/recheck.js +11 -10
- package/dist/events/recheck.js.map +1 -1
- package/dist/procedures/collection/admission.d.ts +26 -7
- package/dist/procedures/collection/admission.d.ts.map +1 -1
- package/dist/procedures/collection/admission.js +39 -7
- package/dist/procedures/collection/admission.js.map +1 -1
- package/dist/procedures/collection/changes.d.ts +28 -4
- package/dist/procedures/collection/changes.d.ts.map +1 -1
- package/dist/procedures/collection/changes.js +50 -6
- package/dist/procedures/collection/changes.js.map +1 -1
- package/dist/procedures/collection/hint-filter.d.ts +143 -0
- package/dist/procedures/collection/hint-filter.d.ts.map +1 -0
- package/dist/procedures/collection/hint-filter.js +311 -0
- package/dist/procedures/collection/hint-filter.js.map +1 -0
- package/dist/procedures/collection/index.d.ts +2 -0
- package/dist/procedures/collection/index.d.ts.map +1 -1
- package/dist/procedures/collection/index.js +2 -0
- package/dist/procedures/collection/index.js.map +1 -1
- package/dist/procedures/collection/procedures.d.ts +2 -2
- package/dist/procedures/collection/procedures.d.ts.map +1 -1
- package/dist/procedures/collection/procedures.js +6 -4
- package/dist/procedures/collection/procedures.js.map +1 -1
- package/dist/procedures/index.d.ts +2 -2
- package/dist/procedures/index.d.ts.map +1 -1
- package/dist/procedures/index.js +3 -1
- package/dist/procedures/index.js.map +1 -1
- package/dist/server/error-policy.d.ts +96 -0
- package/dist/server/error-policy.d.ts.map +1 -0
- package/dist/server/error-policy.js +196 -0
- package/dist/server/error-policy.js.map +1 -0
- package/dist/server/error-response.d.ts +30 -0
- package/dist/server/error-response.d.ts.map +1 -0
- package/dist/server/error-response.js +90 -0
- package/dist/server/error-response.js.map +1 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/last-resort.d.ts +17 -0
- package/dist/server/last-resort.d.ts.map +1 -0
- package/dist/server/last-resort.js +19 -0
- package/dist/server/last-resort.js.map +1 -0
- package/dist/server/procedure-server.d.ts +3 -11
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +7 -42
- package/dist/server/procedure-server.js.map +1 -1
- package/dist/server/server.d.ts +43 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +39 -2
- package/dist/server/server.js.map +1 -1
- package/dist/server/types.d.ts +12 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/types.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/http/server/handler.ts +18 -3
- package/src/adapters/port/transport.ts +2 -0
- package/src/adapters/websocket/server/transport.ts +2 -0
- package/src/adapters/websocket/shared/rpc.ts +14 -5
- package/src/browser.ts +11 -0
- package/src/client/errors/index.ts +1 -0
- package/src/client/errors/registry.ts +13 -0
- package/src/client/index.ts +1 -0
- package/src/events/bus.ts +6 -1
- package/src/events/index.ts +1 -1
- package/src/events/publisher.ts +150 -26
- package/src/events/recheck.ts +38 -13
- package/src/procedures/collection/admission.ts +50 -9
- package/src/procedures/collection/changes.ts +68 -7
- package/src/procedures/collection/hint-filter.ts +376 -0
- package/src/procedures/collection/index.ts +3 -0
- package/src/procedures/collection/procedures.ts +8 -6
- package/src/procedures/index.ts +5 -0
- package/src/server/error-policy.ts +263 -0
- package/src/server/error-response.ts +91 -0
- package/src/server/index.ts +2 -0
- package/src/server/last-resort.ts +23 -0
- package/src/server/procedure-server.ts +8 -41
- package/src/server/server.ts +73 -3
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
471
|
-
|
|
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,
|
|
@@ -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
|
*/
|
package/src/client/index.ts
CHANGED
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
|
-
|
|
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
|
// =============================================================================
|
package/src/events/index.ts
CHANGED
|
@@ -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,
|
package/src/events/publisher.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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((
|
|
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((
|
|
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 = {
|
|
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
|
-
*
|
|
247
|
-
*
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
/**
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
|
536
|
+
if (!target) return { topic, busTopic: topic };
|
|
463
537
|
const partition = await partitionOfCall(ctx, target.collection, target.partition);
|
|
464
|
-
return
|
|
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((
|
|
510
|
-
for (const
|
|
511
|
-
if ((await this.route(topic, ctx))
|
|
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
|
|
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, () =>
|
|
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
|
}
|
package/src/events/recheck.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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:
|
|
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?.();
|