@procedures/client 0.14.1 → 0.15.1
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 +93 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/client/transport.d.ts.map +1 -1
- package/dist/adapters/http/client/transport.js +4 -0
- package/dist/adapters/http/client/transport.js.map +1 -1
- package/dist/adapters/http/server/handler.d.ts.map +1 -1
- package/dist/adapters/http/server/handler.js +8 -3
- package/dist/adapters/http/server/handler.js.map +1 -1
- package/dist/adapters/local/client/transport.d.ts.map +1 -1
- package/dist/adapters/local/client/transport.js +5 -0
- package/dist/adapters/local/client/transport.js.map +1 -1
- package/dist/browser.d.ts +4 -3
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +2 -0
- package/dist/browser.js.map +1 -1
- package/dist/client/client.js +1 -1
- package/dist/client/client.js.map +1 -1
- package/dist/client/errors/registry.d.ts.map +1 -1
- package/dist/client/errors/registry.js +13 -0
- package/dist/client/errors/registry.js.map +1 -1
- package/dist/client/middleware/rate-limit.d.ts +17 -0
- package/dist/client/middleware/rate-limit.d.ts.map +1 -1
- package/dist/client/middleware/rate-limit.js +94 -39
- package/dist/client/middleware/rate-limit.js.map +1 -1
- package/dist/client/types.d.ts +6 -1
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/types.js +7 -1
- package/dist/client/types.js.map +1 -1
- package/dist/events/index.d.ts +1 -0
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js.map +1 -1
- package/dist/events/publisher.d.ts +46 -1
- package/dist/events/publisher.d.ts.map +1 -1
- package/dist/events/publisher.js +95 -5
- package/dist/events/publisher.js.map +1 -1
- package/dist/events/recheck.d.ts +84 -0
- package/dist/events/recheck.d.ts.map +1 -0
- package/dist/events/recheck.js +135 -0
- package/dist/events/recheck.js.map +1 -0
- package/dist/procedures/collection/index.d.ts +3 -1
- 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/precondition.d.ts +111 -0
- package/dist/procedures/collection/precondition.d.ts.map +1 -0
- package/dist/procedures/collection/precondition.js +153 -0
- package/dist/procedures/collection/precondition.js.map +1 -0
- package/dist/procedures/collection/procedures.d.ts.map +1 -1
- package/dist/procedures/collection/procedures.js +16 -3
- package/dist/procedures/collection/procedures.js.map +1 -1
- package/dist/procedures/collection/query.d.ts +2 -0
- package/dist/procedures/collection/query.d.ts.map +1 -1
- package/dist/procedures/collection/query.js +3 -0
- package/dist/procedures/collection/query.js.map +1 -1
- package/dist/procedures/collection/schemas.d.ts +16 -0
- package/dist/procedures/collection/schemas.d.ts.map +1 -1
- package/dist/procedures/collection/schemas.js +26 -5
- package/dist/procedures/collection/schemas.js.map +1 -1
- package/dist/procedures/collection/storage.d.ts +34 -6
- package/dist/procedures/collection/storage.d.ts.map +1 -1
- package/dist/procedures/collection/storage.js +3 -0
- package/dist/procedures/collection/storage.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 +1 -1
- package/dist/procedures/index.js.map +1 -1
- package/dist/protocol.d.ts +1 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +2 -0
- package/dist/protocol.js.map +1 -1
- package/dist/server/host-policy.d.ts +17 -0
- package/dist/server/host-policy.d.ts.map +1 -1
- package/dist/server/host-policy.js +7 -0
- package/dist/server/host-policy.js.map +1 -1
- package/dist/server/limits.d.ts +122 -0
- package/dist/server/limits.d.ts.map +1 -0
- package/dist/server/limits.js +153 -0
- package/dist/server/limits.js.map +1 -0
- package/dist/server/procedure-server.d.ts +31 -1
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +43 -2
- package/dist/server/procedure-server.js.map +1 -1
- package/dist/server/request-auth.d.ts +5 -1
- package/dist/server/request-auth.d.ts.map +1 -1
- package/dist/server/request-auth.js +19 -2
- package/dist/server/request-auth.js.map +1 -1
- package/package.json +3 -3
- package/src/adapters/http/client/transport.ts +3 -0
- package/src/adapters/http/server/handler.ts +8 -3
- package/src/adapters/local/client/transport.ts +4 -0
- package/src/browser.ts +20 -0
- package/src/client/client.ts +1 -1
- package/src/client/errors/registry.ts +14 -0
- package/src/client/middleware/rate-limit.ts +116 -41
- package/src/client/types.ts +8 -0
- package/src/events/index.ts +1 -0
- package/src/events/publisher.ts +116 -6
- package/src/events/recheck.ts +182 -0
- package/src/procedures/collection/index.ts +17 -0
- package/src/procedures/collection/precondition.ts +180 -0
- package/src/procedures/collection/procedures.ts +17 -4
- package/src/procedures/collection/query.ts +16 -0
- package/src/procedures/collection/schemas.ts +43 -6
- package/src/procedures/collection/storage.ts +36 -6
- package/src/procedures/index.ts +15 -0
- package/src/protocol.ts +5 -1
- package/src/server/host-policy.ts +22 -0
- package/src/server/limits.ts +254 -0
- package/src/server/procedure-server.ts +59 -3
- package/src/server/request-auth.ts +19 -2
package/src/client/types.ts
CHANGED
|
@@ -326,12 +326,20 @@ export type AccumulateContext<
|
|
|
326
326
|
* Client error thrown when response has error status.
|
|
327
327
|
*/
|
|
328
328
|
export class ClientError extends Error {
|
|
329
|
+
/**
|
|
330
|
+
* The metadata of the error response, for example `reason` of a `FORBIDDEN` error, or
|
|
331
|
+
* `conflicts` of a `VERSION_CONFLICT` error (roadmap CLI-16). Empty when the response has none.
|
|
332
|
+
*/
|
|
333
|
+
public readonly metadata: Metadata;
|
|
334
|
+
|
|
329
335
|
constructor(
|
|
330
336
|
public readonly status: Extract<Status, { type: "error" }>,
|
|
331
337
|
public readonly responseId: string,
|
|
338
|
+
metadata: Metadata = {},
|
|
332
339
|
) {
|
|
333
340
|
super(status.message);
|
|
334
341
|
this.name = "ClientError";
|
|
342
|
+
this.metadata = metadata;
|
|
335
343
|
}
|
|
336
344
|
|
|
337
345
|
get code(): string {
|
package/src/events/index.ts
CHANGED
|
@@ -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,
|
package/src/events/publisher.ts
CHANGED
|
@@ -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;
|
|
@@ -89,6 +94,23 @@ export interface EventPublisherOptions {
|
|
|
89
94
|
* @default 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:
|
|
@@ -385,11 +470,10 @@ export class EventPublisher {
|
|
|
385
470
|
}
|
|
386
471
|
|
|
387
472
|
/**
|
|
388
|
-
* The
|
|
389
|
-
*
|
|
473
|
+
* The checks of the topics of a call: the topic rule, the reads of the capability and `allow`.
|
|
474
|
+
* A subscription passes them at its start and at each re-check (roadmap CLI-44).
|
|
390
475
|
*/
|
|
391
|
-
private
|
|
392
|
-
const unique = [...new Set(topics)];
|
|
476
|
+
private admitTopics(unique: readonly string[], ctx: ProcedureContext): void {
|
|
393
477
|
for (const topic of unique) {
|
|
394
478
|
if (!this.known(topic)) throw new TopicNotFoundError(`The host has no topic "${topic}"`);
|
|
395
479
|
}
|
|
@@ -408,10 +492,36 @@ export class EventPublisher {
|
|
|
408
492
|
}
|
|
409
493
|
}
|
|
410
494
|
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The admission of an open subscription again (roadmap CLI-44): the checks of its start, and the
|
|
499
|
+
* partition of each topic. A refusal of the resolver throws its error. A topic whose partition
|
|
500
|
+
* changed throws `FORBIDDEN` (reason `partition-not-allowed`), because the listener has the old
|
|
501
|
+
* partition.
|
|
502
|
+
*/
|
|
503
|
+
private async readmit(routes: ReadonlyArray<readonly [string, string]>, ctx: ProcedureContext): Promise<void> {
|
|
504
|
+
this.admitTopics(routes.map(([topic]) => topic), ctx);
|
|
505
|
+
for (const [topic, busTopic] of routes) {
|
|
506
|
+
if ((await this.busTopic(topic, ctx)) !== busTopic) {
|
|
507
|
+
throw new InvocationError("FORBIDDEN", `The partition of the topic "${topic}" changed, so the subscription ends`, ctx.path, "partition-not-allowed");
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* The stream of one subscription. The publisher examines each topic before it adds a listener:
|
|
514
|
+
* the topic rule, the reads, `allow`, and the partition. A call with one bad topic makes no subscription.
|
|
515
|
+
*/
|
|
516
|
+
private async subscribe(topics: readonly string[], ctx: ProcedureContext): Promise<TopicSubscription> {
|
|
517
|
+
const unique = [...new Set(topics)];
|
|
518
|
+
this.admitTopics(unique, ctx);
|
|
411
519
|
const routes: Array<readonly [string, string]> = [];
|
|
412
520
|
for (const topic of unique) routes.push([topic, await this.busTopic(topic, ctx)]);
|
|
413
521
|
const subscription = new TopicSubscription(this.bus, routes, this.buffer, ctx.signal, (closed) => this.open.delete(closed), ctx.admit, ctx.invocation);
|
|
414
522
|
this.open.add(subscription);
|
|
523
|
+
// The same checks run again at a change of the rights and after the max age (roadmap CLI-44)
|
|
524
|
+
this.rechecks.watch(subscription, ctx.principal, () => this.readmit(routes, ctx));
|
|
415
525
|
return subscription.closeIfAborted();
|
|
416
526
|
}
|
|
417
527
|
}
|
|
@@ -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
|
+
}
|
|
@@ -84,6 +84,8 @@ export type {
|
|
|
84
84
|
StorageChanges,
|
|
85
85
|
StorageMetadata,
|
|
86
86
|
StorageWriteOptions,
|
|
87
|
+
StorageItemWriteOptions,
|
|
88
|
+
StorageBatchWriteOptions,
|
|
87
89
|
StorageSetChange,
|
|
88
90
|
StorageDeleteChange,
|
|
89
91
|
StorageClearChange,
|
|
@@ -92,6 +94,21 @@ export type {
|
|
|
92
94
|
StorageObserver,
|
|
93
95
|
} from "./storage.js";
|
|
94
96
|
|
|
97
|
+
// The precondition of the writes: the expected version and VERSION_CONFLICT (roadmap CLI-16)
|
|
98
|
+
export {
|
|
99
|
+
MAX_EXPECTED_VERSION_LENGTH,
|
|
100
|
+
PRECONDITION_NOT_SUPPORTED,
|
|
101
|
+
VERSION_CONFLICTS_METADATA_KEY,
|
|
102
|
+
VersionConflictError,
|
|
103
|
+
asVersionConflictError,
|
|
104
|
+
isExpectedVersion,
|
|
105
|
+
preconditionNotSupported,
|
|
106
|
+
versionConflict,
|
|
107
|
+
versionConflicts,
|
|
108
|
+
versionConflictsOf,
|
|
109
|
+
} from "./precondition.js";
|
|
110
|
+
export type { CurrentItem, VersionConflict, VersionOf } from "./precondition.js";
|
|
111
|
+
|
|
95
112
|
// The query language of the storages: the same JSON on the wire and in query() (roadmap CLI-34:
|
|
96
113
|
// the one copy of the language)
|
|
97
114
|
export {
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The precondition of a collection write (roadmap CLI-16, decision D11).
|
|
3
|
+
*
|
|
4
|
+
* - `set` and `delete` take `expectedVersion`. `setBatch` takes `expectedVersions`, with one
|
|
5
|
+
* expected version for each id. The value is the version of the current item that the write
|
|
6
|
+
* expects. `null` expects no item.
|
|
7
|
+
* - The version of an item is the value that the function `versionOf` of the storage gives for
|
|
8
|
+
* the item, for example a revision field. `HybridStorage` reads the same versions with its own
|
|
9
|
+
* `versionOf`, so its queue can send the base version of a write as the expected version.
|
|
10
|
+
* - The storage tests the precondition and writes in one step. When the version of the current
|
|
11
|
+
* item is not the expected version, the write changes nothing, and it rejects with
|
|
12
|
+
* `VersionConflictError`.
|
|
13
|
+
* - The error has the code `VERSION_CONFLICT` (HTTP 409). It has the current item and its version
|
|
14
|
+
* for each conflict. On the wire, the conflicts go in the metadata key `conflicts`.
|
|
15
|
+
* - A storage without `versionOf` cannot test a precondition. It rejects such a write with a
|
|
16
|
+
* `TypeError`, and changes nothing. It never ignores a precondition.
|
|
17
|
+
* - A write without a precondition is the same as before: the last write wins.
|
|
18
|
+
*
|
|
19
|
+
* This module has no imports: `@procedures/client/query` exports it for the storage packages.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** One conflict of a write with a precondition (roadmap CLI-16). The data is JSON. */
|
|
23
|
+
export interface VersionConflict {
|
|
24
|
+
/** The id of the item. */
|
|
25
|
+
readonly id: string;
|
|
26
|
+
/** The version that the write expected. `null`: the write expected no item. */
|
|
27
|
+
readonly expected: string | null;
|
|
28
|
+
/** True when the id has an item now. */
|
|
29
|
+
readonly found: boolean;
|
|
30
|
+
/** The current item, when `found` is true. */
|
|
31
|
+
readonly value?: unknown;
|
|
32
|
+
/**
|
|
33
|
+
* The version of the current item: `null` when the id has no item. When the current item has no
|
|
34
|
+
* version, the field is absent.
|
|
35
|
+
*/
|
|
36
|
+
readonly version?: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The function that gives the version of an item, or undefined for an item without a version. */
|
|
40
|
+
export type VersionOf<T> = (item: T) => string | undefined;
|
|
41
|
+
|
|
42
|
+
/** The current state of an id: no item, or an item. */
|
|
43
|
+
export type CurrentItem<T> = { readonly found: false } | { readonly found: true; readonly value: T };
|
|
44
|
+
|
|
45
|
+
/** The metadata key of the conflicts of a `VERSION_CONFLICT` error on the wire. */
|
|
46
|
+
export const VERSION_CONFLICTS_METADATA_KEY = "conflicts";
|
|
47
|
+
|
|
48
|
+
/** The longest expected version, in characters. */
|
|
49
|
+
export const MAX_EXPECTED_VERSION_LENGTH = 1024;
|
|
50
|
+
|
|
51
|
+
/** The message of an error with some conflicts. */
|
|
52
|
+
function conflictMessage(conflicts: readonly VersionConflict[]): string {
|
|
53
|
+
const ids = conflicts.slice(0, 10).map((conflict) => JSON.stringify(conflict.id)).join(", ");
|
|
54
|
+
const more = conflicts.length > 10 ? ` and ${conflicts.length - 10} more` : "";
|
|
55
|
+
return `The version of the current item is not the expected version: ${ids}${more}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A write with a precondition found an item with another version (roadmap CLI-16). The write
|
|
60
|
+
* changed nothing. `conflicts` has the current item and its version for each id of a conflict.
|
|
61
|
+
*/
|
|
62
|
+
export class VersionConflictError extends Error {
|
|
63
|
+
override readonly name = "VersionConflictError";
|
|
64
|
+
/** The error code of the protocol: HTTP 409, not retryable. */
|
|
65
|
+
readonly code = "VERSION_CONFLICT";
|
|
66
|
+
/** A retry of the same write fails again: the caller reads the current item first. */
|
|
67
|
+
readonly retryable = false;
|
|
68
|
+
/** The conflicts, one for each id whose current item does not have the expected version. */
|
|
69
|
+
readonly conflicts: readonly VersionConflict[];
|
|
70
|
+
|
|
71
|
+
constructor(conflicts: readonly VersionConflict[], message?: string) {
|
|
72
|
+
super(message ?? conflictMessage(conflicts));
|
|
73
|
+
this.conflicts = conflicts;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* This function gives the conflict of one id, or undefined when the current item has the expected
|
|
79
|
+
* version. `null` expects no item. An item without a version never has an expected string version.
|
|
80
|
+
*
|
|
81
|
+
* @param id - The id of the item
|
|
82
|
+
* @param expected - The expected version, or `null` for no item
|
|
83
|
+
* @param current - The current state of the id
|
|
84
|
+
* @param versionOf - The version of an item
|
|
85
|
+
*/
|
|
86
|
+
export function versionConflict<T>(id: string, expected: string | null, current: CurrentItem<T>, versionOf: VersionOf<T>): VersionConflict | undefined {
|
|
87
|
+
if (!current.found) return expected === null ? undefined : { id, expected, found: false, version: null };
|
|
88
|
+
const version = versionOf(current.value);
|
|
89
|
+
if (expected !== null && version === expected) return undefined;
|
|
90
|
+
return version === undefined ? { id, expected, found: true, value: current.value } : { id, expected, found: true, value: current.value, version };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* This function tests the preconditions of a write against the current items, and gives the
|
|
95
|
+
* conflicts. The list is empty when each precondition holds.
|
|
96
|
+
*
|
|
97
|
+
* @param expected - The expected version of each id
|
|
98
|
+
* @param currentOf - The current state of an id, before the write
|
|
99
|
+
* @param versionOf - The version of an item
|
|
100
|
+
*/
|
|
101
|
+
export function versionConflicts<T>(
|
|
102
|
+
expected: Iterable<readonly [string, string | null]>,
|
|
103
|
+
currentOf: (id: string) => CurrentItem<T>,
|
|
104
|
+
versionOf: VersionOf<T>,
|
|
105
|
+
): VersionConflict[] {
|
|
106
|
+
const conflicts: VersionConflict[] = [];
|
|
107
|
+
for (const [id, version] of expected) {
|
|
108
|
+
const conflict = versionConflict(id, version, currentOf(id), versionOf);
|
|
109
|
+
if (conflict) conflicts.push(conflict);
|
|
110
|
+
}
|
|
111
|
+
return conflicts;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The code of the error of a write with a precondition on a storage that cannot test it. */
|
|
115
|
+
export const PRECONDITION_NOT_SUPPORTED = "PRECONDITION_NOT_SUPPORTED";
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The error of a write with a precondition on a storage that cannot test it: a storage without
|
|
119
|
+
* `versionOf`. The storage changes nothing. The error is a `TypeError` with the code
|
|
120
|
+
* `PRECONDITION_NOT_SUPPORTED`, so a sync does not take it for a network failure.
|
|
121
|
+
*
|
|
122
|
+
* @param storage - The name of the storage, for the message
|
|
123
|
+
* @param remedy - What makes the test possible
|
|
124
|
+
*/
|
|
125
|
+
export function preconditionNotSupported(storage: string, remedy = "give it the option versionOf"): TypeError & { readonly code: string; readonly retryable: false } {
|
|
126
|
+
return Object.assign(new TypeError(`${storage} cannot test expectedVersion: ${remedy}`), { code: PRECONDITION_NOT_SUPPORTED, retryable: false as const });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** True for a value that a precondition can expect: a string of at most 1024 characters, or `null`. */
|
|
130
|
+
export function isExpectedVersion(value: unknown): value is string | null {
|
|
131
|
+
return value === null || (typeof value === "string" && value.length <= MAX_EXPECTED_VERSION_LENGTH);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The conflicts of untrusted data from the wire, or undefined when the data is not a list of conflicts. */
|
|
135
|
+
function conflictsOfData(data: unknown): VersionConflict[] | undefined {
|
|
136
|
+
if (!Array.isArray(data)) return undefined;
|
|
137
|
+
const conflicts: VersionConflict[] = [];
|
|
138
|
+
for (const entry of data) {
|
|
139
|
+
if (typeof entry !== "object" || entry === null) return undefined;
|
|
140
|
+
const record = entry as Record<string, unknown>;
|
|
141
|
+
const { id, expected, found, version } = record;
|
|
142
|
+
if (typeof id !== "string" || typeof found !== "boolean" || !(expected === null || typeof expected === "string")) return undefined;
|
|
143
|
+
if (!(version === undefined || version === null || typeof version === "string")) return undefined;
|
|
144
|
+
conflicts.push({
|
|
145
|
+
id,
|
|
146
|
+
expected,
|
|
147
|
+
found,
|
|
148
|
+
...(found && Object.prototype.hasOwnProperty.call(record, "value") && { value: record["value"] }),
|
|
149
|
+
...(version !== undefined && { version }),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return conflicts;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* This function gives the conflicts of an error with the code `VERSION_CONFLICT`: the `conflicts`
|
|
157
|
+
* of a `VersionConflictError`, or the metadata key `conflicts` of an error from the wire (a
|
|
158
|
+
* `ClientError`). It gives undefined for another error. A `VERSION_CONFLICT` error without
|
|
159
|
+
* readable conflicts gives an empty list.
|
|
160
|
+
*/
|
|
161
|
+
export function versionConflictsOf(error: unknown): readonly VersionConflict[] | undefined {
|
|
162
|
+
if (typeof error !== "object" || error === null) return undefined;
|
|
163
|
+
if (error instanceof VersionConflictError) return error.conflicts;
|
|
164
|
+
const { code, conflicts, metadata } = error as { code?: unknown; conflicts?: unknown; metadata?: unknown };
|
|
165
|
+
if (code !== "VERSION_CONFLICT") return undefined;
|
|
166
|
+
const fromMetadata = typeof metadata === "object" && metadata !== null ? (metadata as Record<string, unknown>)[VERSION_CONFLICTS_METADATA_KEY] : undefined;
|
|
167
|
+
return conflictsOfData(conflicts) ?? conflictsOfData(fromMetadata) ?? [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* This function gives a `VersionConflictError` for an error with the code `VERSION_CONFLICT`, with
|
|
172
|
+
* its conflicts and its message. It gives undefined for another error.
|
|
173
|
+
*/
|
|
174
|
+
export function asVersionConflictError(error: unknown): VersionConflictError | undefined {
|
|
175
|
+
if (error instanceof VersionConflictError) return error;
|
|
176
|
+
const conflicts = versionConflictsOf(error);
|
|
177
|
+
if (conflicts === undefined) return undefined;
|
|
178
|
+
const message = error instanceof Error ? error.message : undefined;
|
|
179
|
+
return new VersionConflictError(conflicts, message);
|
|
180
|
+
}
|