@procedures/client 0.48.0 → 0.48.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 +8 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/browser.d.ts +2 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +2 -0
- package/dist/browser.js.map +1 -1
- package/dist/events/index.d.ts +4 -0
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js +3 -0
- package/dist/events/index.js.map +1 -1
- package/dist/events/live-state.d.ts +289 -0
- package/dist/events/live-state.d.ts.map +1 -0
- package/dist/events/live-state.js +501 -0
- package/dist/events/live-state.js.map +1 -0
- package/dist/events/live-view.d.ts +38 -0
- package/dist/events/live-view.d.ts.map +1 -0
- package/dist/events/live-view.js +109 -0
- package/dist/events/live-view.js.map +1 -0
- package/dist/events/publisher.d.ts +18 -1
- package/dist/events/publisher.d.ts.map +1 -1
- package/dist/events/publisher.js +23 -1
- package/dist/events/publisher.js.map +1 -1
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +3 -0
- package/dist/server/procedure-server.js.map +1 -1
- package/package.json +1 -1
- package/src/browser.ts +19 -0
- package/src/events/index.ts +29 -0
- package/src/events/live-state.ts +666 -0
- package/src/events/live-view.ts +134 -0
- package/src/events/publisher.ts +31 -1
- package/src/server/procedure-server.ts +2 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reader of a live topic (documentation/LIVE-STATE-2026-11.md).
|
|
3
|
+
*
|
|
4
|
+
* The view subscribes first and reads the snapshot second. It applies the snapshot, then the events
|
|
5
|
+
* that came during the read, in their order. So no number is necessary: each set publishes an
|
|
6
|
+
* event, and the last event of a key decides. After a lost connection and after a loss of events,
|
|
7
|
+
* the view reads the snapshot again and replaces its values.
|
|
8
|
+
*/
|
|
9
|
+
import { pathToMethod } from "../server/method.js";
|
|
10
|
+
import type { Method } from "../client/types.js";
|
|
11
|
+
import { LIVE_SNAPSHOT_PATH, type LiveEntry, type LiveEvent } from "./live-state.js";
|
|
12
|
+
import type { RemoteEventHandler, SubscribeOptions, Subscription } from "./remote.js";
|
|
13
|
+
|
|
14
|
+
/** The parts of a client that a view uses. A `Client` has them. */
|
|
15
|
+
export interface LiveClient {
|
|
16
|
+
/** This method starts a subscription. */
|
|
17
|
+
subscribe<T = unknown>(topics: string | readonly string[], handler: RemoteEventHandler<T>, options?: SubscribeOptions): Subscription;
|
|
18
|
+
/** This method calls a procedure. */
|
|
19
|
+
call<TReq, TRes>(method: Method, payload: TReq): Promise<TRes>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The options of a view. */
|
|
23
|
+
export interface LiveViewOptions<T = unknown> extends SubscribeOptions {
|
|
24
|
+
/** This function gets the values after each change. */
|
|
25
|
+
onChange?: ((values: ReadonlyMap<string, LiveEntry<T>>) => void) | undefined;
|
|
26
|
+
/** This function gets the error of a read of the snapshot. The view keeps its values and its subscription. */
|
|
27
|
+
onReadError?: ((error: unknown) => void) | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The live values of a topic for one reader. */
|
|
31
|
+
export interface LiveView<T = unknown> {
|
|
32
|
+
/** The live values by key. */
|
|
33
|
+
readonly values: ReadonlyMap<string, LiveEntry<T>>;
|
|
34
|
+
/** This promise resolves after the first snapshot. It rejects when the first subscription or read fails. */
|
|
35
|
+
readonly ready: Promise<void>;
|
|
36
|
+
/** This method ends the subscription. A second call does nothing. */
|
|
37
|
+
close(): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* This function gives a view of a live topic.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* const view = liveView(client, liveTopic("presence", "board-1"), { onChange: render });
|
|
46
|
+
* await view.ready;
|
|
47
|
+
* view.close();
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function liveView<T = unknown>(client: LiveClient, topic: string, options: LiveViewOptions<T> = {}): LiveView<T> {
|
|
51
|
+
const { onChange, onReadError, ...subscribeOptions } = options;
|
|
52
|
+
const values = new Map<string, LiveEntry<T>>();
|
|
53
|
+
/** This field holds the events that come during a read of the snapshot. It is undefined when no read runs. */
|
|
54
|
+
let held: Array<LiveEvent<T>> | undefined;
|
|
55
|
+
let reads = 0;
|
|
56
|
+
let closed = false;
|
|
57
|
+
let settled = false;
|
|
58
|
+
let resolveReady: () => void = () => undefined;
|
|
59
|
+
let rejectReady: (error: unknown) => void = () => undefined;
|
|
60
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
61
|
+
resolveReady = resolve;
|
|
62
|
+
rejectReady = reject;
|
|
63
|
+
});
|
|
64
|
+
// A view that nobody awaits does not give an unhandled rejection
|
|
65
|
+
ready.catch(() => undefined);
|
|
66
|
+
|
|
67
|
+
const apply = (event: LiveEvent<T>): void => {
|
|
68
|
+
for (const { key } of event.gone) values.delete(key);
|
|
69
|
+
for (const entry of event.set) values.set(entry.key, entry);
|
|
70
|
+
};
|
|
71
|
+
const applyHeld = (): void => {
|
|
72
|
+
for (const event of held ?? []) apply(event);
|
|
73
|
+
held = undefined;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const read = async (): Promise<void> => {
|
|
77
|
+
const number = ++reads;
|
|
78
|
+
held = [];
|
|
79
|
+
try {
|
|
80
|
+
const { entries } = await client.call<{ topic: string }, { entries: Array<LiveEntry<T>> }>(pathToMethod([...LIVE_SNAPSHOT_PATH]), { topic });
|
|
81
|
+
if (closed || number !== reads) return;
|
|
82
|
+
values.clear();
|
|
83
|
+
for (const entry of entries) values.set(entry.key, entry);
|
|
84
|
+
applyHeld();
|
|
85
|
+
settled = true;
|
|
86
|
+
resolveReady();
|
|
87
|
+
onChange?.(values);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (closed || number !== reads) return;
|
|
90
|
+
applyHeld();
|
|
91
|
+
if (!settled) {
|
|
92
|
+
settled = true;
|
|
93
|
+
rejectReady(error);
|
|
94
|
+
}
|
|
95
|
+
onReadError?.(error);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const unsubscribe = client.subscribe<LiveEvent<T>>(
|
|
100
|
+
topic,
|
|
101
|
+
(event, meta) => {
|
|
102
|
+
if (closed) return;
|
|
103
|
+
// A loss of events: only a new read gives the keys back
|
|
104
|
+
if (meta.dropped > 0) void read();
|
|
105
|
+
if (held) {
|
|
106
|
+
held.push(event);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
apply(event);
|
|
110
|
+
onChange?.(values);
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
...subscribeOptions,
|
|
114
|
+
onSubscribed: (topics) => {
|
|
115
|
+
subscribeOptions.onSubscribed?.(topics);
|
|
116
|
+
if (!closed) void read();
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
unsubscribe.ready.catch((error: unknown) => {
|
|
121
|
+
if (settled) return;
|
|
122
|
+
settled = true;
|
|
123
|
+
rejectReady(error);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
values,
|
|
128
|
+
ready,
|
|
129
|
+
close: () => {
|
|
130
|
+
closed = true;
|
|
131
|
+
unsubscribe();
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
package/src/events/publisher.ts
CHANGED
|
@@ -42,6 +42,7 @@ import type { AnyProcedure, Capability, ProcedureContext } from "../procedures/t
|
|
|
42
42
|
import type { Principal } from "../server/types.js";
|
|
43
43
|
import type { EventBus, Unsubscribe } from "./types.js";
|
|
44
44
|
import { getGlobalEventBus } from "./bus.js";
|
|
45
|
+
import { LiveState, type LiveStateOptions } from "./live-state.js";
|
|
45
46
|
import { EVENTS_SUBSCRIBE_PATH, type EventItem, type SubscribeInput } from "./remote.js";
|
|
46
47
|
import { admittedValue, rowsOf } from "./rows.js";
|
|
47
48
|
import { realClock, type Clock } from "../clock.js";
|
|
@@ -117,6 +118,13 @@ export interface EventPublisherOptions {
|
|
|
117
118
|
* @default no check after the start, other than at a call of `rightsChanged()`
|
|
118
119
|
*/
|
|
119
120
|
recheck?: SubscriptionRecheck | undefined;
|
|
121
|
+
/**
|
|
122
|
+
* The live state of the host (LIVE-STATE-2026-11.md): keyed values in memory on the topics
|
|
123
|
+
* `live.<name>` and `live.<name>.<partition>`. The topic rule must name these topics. A server
|
|
124
|
+
* registers `live.set` and `live.snapshot`, and its expose rule applies to them.
|
|
125
|
+
* @default no live state
|
|
126
|
+
*/
|
|
127
|
+
live?: LiveStateOptions | undefined;
|
|
120
128
|
}
|
|
121
129
|
|
|
122
130
|
/** A procedure that reads resources, for the admission of a topic under a capability. */
|
|
@@ -468,6 +476,8 @@ export class EventPublisher {
|
|
|
468
476
|
readonly bus: EventBus;
|
|
469
477
|
/** The procedure `events.subscribe` of this publisher. A host pins it, outside its registry. */
|
|
470
478
|
readonly procedure: AnyProcedure;
|
|
479
|
+
/** The live state, from the option `live`, or undefined. */
|
|
480
|
+
readonly live: LiveState | undefined;
|
|
471
481
|
private readonly known: (topic: string) => boolean;
|
|
472
482
|
private readonly allow: EventPublisherOptions["allow"];
|
|
473
483
|
private readonly reads: (topic: string) => readonly string[];
|
|
@@ -497,6 +507,14 @@ export class EventPublisher {
|
|
|
497
507
|
})
|
|
498
508
|
.handler((async (input: SubscribeInput, ctx: ProcedureContext) => this.subscribe(input.topics, ctx)) as never)
|
|
499
509
|
.build();
|
|
510
|
+
this.live = options.live
|
|
511
|
+
? new LiveState(options.live, {
|
|
512
|
+
bus: this.bus,
|
|
513
|
+
clock: options.clock,
|
|
514
|
+
topicSchema,
|
|
515
|
+
admit: (topic, ctx) => this.admitTopic(topic, ctx),
|
|
516
|
+
})
|
|
517
|
+
: undefined;
|
|
500
518
|
}
|
|
501
519
|
|
|
502
520
|
/** This function publishes an event on the bus: each subscription of the topic gets it. */
|
|
@@ -515,6 +533,7 @@ export class EventPublisher {
|
|
|
515
533
|
*/
|
|
516
534
|
end(): void {
|
|
517
535
|
for (const subscription of [...this.open]) subscription.fail(stoppedError());
|
|
536
|
+
this.live?.close();
|
|
518
537
|
}
|
|
519
538
|
|
|
520
539
|
/**
|
|
@@ -546,11 +565,22 @@ export class EventPublisher {
|
|
|
546
565
|
*/
|
|
547
566
|
private async route(topic: string, ctx: ProcedureContext): Promise<Route> {
|
|
548
567
|
const target = collectionTopicTarget(topic, ctx.repository);
|
|
549
|
-
if (!target) return { topic, busTopic: topic };
|
|
568
|
+
if (!target) return { topic, busTopic: (await this.live?.route(topic, ctx)) ?? topic };
|
|
550
569
|
const partition = await partitionOfCall(ctx, target.collection, target.partition);
|
|
551
570
|
return { topic, busTopic: collectionChangeTopic(target.collection, partition), collection: target.collection, partition, requested: target.partition };
|
|
552
571
|
}
|
|
553
572
|
|
|
573
|
+
/**
|
|
574
|
+
* This method runs the checks of a subscription on one topic: the topic rule, the reads, `allow`
|
|
575
|
+
* and the partition. A live set and a snapshot pass the same checks as a subscriber.
|
|
576
|
+
*
|
|
577
|
+
* @returns The topic of the bus
|
|
578
|
+
*/
|
|
579
|
+
async admitTopic(topic: string, ctx: ProcedureContext): Promise<string> {
|
|
580
|
+
this.admitTopics([topic], ctx);
|
|
581
|
+
return (await this.route(topic, ctx)).busTopic;
|
|
582
|
+
}
|
|
583
|
+
|
|
554
584
|
/**
|
|
555
585
|
* The resources that a subscriber of a topic reads. The default (`collectionTopicReads`) reads
|
|
556
586
|
* the collection of the topic with the rule of the listener (`collectionTopicTarget`) and the
|
|
@@ -516,6 +516,8 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
516
516
|
const events = options.events;
|
|
517
517
|
this.publisher = events instanceof EventPublisher ? events : events ? new EventPublisher({ ...events, clock: events.clock ?? this.clock }) : undefined;
|
|
518
518
|
if (this.publisher) this.registerProcedure(this.publisher.procedure, { alwaysOn: true });
|
|
519
|
+
// The live state is a choice of the host, so its expose rule applies to these procedures
|
|
520
|
+
for (const procedure of this.publisher?.live?.procedures ?? []) this.registerProcedure(procedure);
|
|
519
521
|
|
|
520
522
|
// Configure collections
|
|
521
523
|
if (options.collections) {
|