@procedures/client 0.48.0 → 0.48.2
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 +17 -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/procedures/collection/admission.d.ts.map +1 -1
- package/dist/procedures/collection/admission.js +3 -2
- package/dist/procedures/collection/admission.js.map +1 -1
- package/dist/procedures/collection/changes.d.ts +16 -0
- package/dist/procedures/collection/changes.d.ts.map +1 -1
- package/dist/procedures/collection/changes.js +81 -9
- package/dist/procedures/collection/changes.js.map +1 -1
- package/dist/procedures/collection/procedures.d.ts.map +1 -1
- package/dist/procedures/collection/procedures.js +8 -3
- package/dist/procedures/collection/procedures.js.map +1 -1
- package/dist/procedures/collection/storage.d.ts +10 -0
- package/dist/procedures/collection/storage.d.ts.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 +2 -2
- 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/procedures/collection/admission.ts +3 -2
- package/src/procedures/collection/changes.ts +87 -9
- package/src/procedures/collection/procedures.ts +7 -2
- package/src/procedures/collection/storage.ts +10 -0
- 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
|
|
@@ -265,9 +265,10 @@ export function admittedStorage<T>(storage: CollectionStorage<T>, admission: Col
|
|
|
265
265
|
/** The full answer of `changes`: each admitted row, at a version that the rows include. */
|
|
266
266
|
const fullAnswer = async (): Promise<StorageChanges<T>> => {
|
|
267
267
|
const all = await storage.getChangesSince!(undefined);
|
|
268
|
-
|
|
268
|
+
const held = all.held ? { held: true } : {};
|
|
269
|
+
if (all.full) return { version: all.version, full: true, changed: await admitted(all.changed), deleted: [], ...held };
|
|
269
270
|
// A log that does not give all rows: the rows, read after the version, include that version
|
|
270
|
-
return { version: all.version, full: true, changed: await entries(), deleted: [] };
|
|
271
|
+
return { version: all.version, full: true, changed: await entries(), deleted: [], ...held };
|
|
271
272
|
};
|
|
272
273
|
|
|
273
274
|
const view: CollectionStorage<T> = {
|
|
@@ -22,6 +22,16 @@
|
|
|
22
22
|
* listens to the topic, the procedures publish nothing, and the chain stops.
|
|
23
23
|
* - The publishes of one storage and one topic run one at a time, in the order of the writes. A
|
|
24
24
|
* write resolves after its event is on the bus. A failed publish never fails the write.
|
|
25
|
+
* - The writes that end while a publish runs share the next publish, so one read of the change
|
|
26
|
+
* log replies to all of them. At most one publish runs, and at most one publish waits.
|
|
27
|
+
* - A version can stop below a write in flight (`StorageChanges.held`, roadmap CLI-102). The chain
|
|
28
|
+
* then reads the log again, after a wait that starts at 100 ms and doubles up to 5 s. It stops when
|
|
29
|
+
* no write holds the version or no one listens. Thus a write that a write in flight holds back
|
|
30
|
+
* reaches the subscribers with no later write. The event of such a read has no origin. It has
|
|
31
|
+
* no rows before the changes, so a row filter makes a hint without ids.
|
|
32
|
+
* - The log can give a write two times (`StorageChanges`), so a subscriber applies a change by its id.
|
|
33
|
+
* - A read with no change sends no event, and the chain keeps its version. The version of a log can
|
|
34
|
+
* move with no change, when another process writes a row again at a later number.
|
|
25
35
|
* - With the rule `admission.admit`, the publisher filters the ids of each event for each
|
|
26
36
|
* subscriber (roadmap CLI-78, `hint-filter.ts`). The event carries the rows after the changes,
|
|
27
37
|
* from the change log. While a subscriber has a filter, each write first reads the rows before
|
|
@@ -103,6 +113,11 @@ interface ChangeLog {
|
|
|
103
113
|
getBatch?(ids: string[]): Promise<Map<string, unknown>>;
|
|
104
114
|
}
|
|
105
115
|
|
|
116
|
+
/** The first wait before a read again of a log that a write in flight holds back. */
|
|
117
|
+
const HELD_FIRST_MS = 100;
|
|
118
|
+
/** The longest wait before a read again. */
|
|
119
|
+
const HELD_MOST_MS = 5000;
|
|
120
|
+
|
|
106
121
|
/** A write that ended, and that no event told yet. */
|
|
107
122
|
interface EndedWrite {
|
|
108
123
|
/** The ids of the write, or "*" for a clear. */
|
|
@@ -120,8 +135,30 @@ interface ChangeChain {
|
|
|
120
135
|
anchor: string | undefined;
|
|
121
136
|
/** The publishes, one at a time. */
|
|
122
137
|
tail: Promise<void>;
|
|
138
|
+
/** The publish that did not start. A write that ends joins it, because it reads the writes that ended before its start. */
|
|
139
|
+
next: Promise<void> | undefined;
|
|
123
140
|
/** The writes that ended since the last publish. */
|
|
124
141
|
readonly ended: EndedWrite[];
|
|
142
|
+
/** True when the next publish reads the log with no write that ended. */
|
|
143
|
+
owed: boolean;
|
|
144
|
+
/** The last wait before a read again, or 0 when no write in flight holds the log back. */
|
|
145
|
+
heldWaitMs: number;
|
|
146
|
+
/** The timer of the next read again. */
|
|
147
|
+
heldTimer: ReturnType<typeof setTimeout> | undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** This function gives the publish that did not start, and it makes one when none waits. A failed publish rejects nothing here. */
|
|
151
|
+
function nextPublish(chain: ChangeChain, start: () => Promise<void>): Promise<void> {
|
|
152
|
+
if (chain.next) return chain.next;
|
|
153
|
+
const next = chain.tail
|
|
154
|
+
.then(() => {
|
|
155
|
+
chain.next = undefined;
|
|
156
|
+
return start();
|
|
157
|
+
})
|
|
158
|
+
.catch(() => undefined);
|
|
159
|
+
chain.next = next;
|
|
160
|
+
chain.tail = next;
|
|
161
|
+
return next;
|
|
125
162
|
}
|
|
126
163
|
|
|
127
164
|
/** The chains of each storage, by topic. A storage that goes away takes its chains with it. */
|
|
@@ -135,7 +172,7 @@ function chainOf(storage: object, topic: string): ChangeChain {
|
|
|
135
172
|
}
|
|
136
173
|
let chain = topics.get(topic);
|
|
137
174
|
if (!chain) {
|
|
138
|
-
chain = { anchor: undefined, tail: Promise.resolve(), ended: [] };
|
|
175
|
+
chain = { anchor: undefined, tail: Promise.resolve(), next: undefined, ended: [], owed: false, heldWaitMs: 0, heldTimer: undefined };
|
|
139
176
|
topics.set(topic, chain);
|
|
140
177
|
}
|
|
141
178
|
return chain;
|
|
@@ -187,20 +224,41 @@ function withValues(event: CollectionChangeEvent, entries: ReadonlyArray<{ reado
|
|
|
187
224
|
);
|
|
188
225
|
}
|
|
189
226
|
|
|
190
|
-
/**
|
|
227
|
+
/** This function starts the timer of the next read again while a write holds the log back, and it stops the timer otherwise. */
|
|
228
|
+
function whileHeld(chain: ChangeChain, held: boolean, read: () => void): void {
|
|
229
|
+
if (!held) {
|
|
230
|
+
chain.heldWaitMs = 0;
|
|
231
|
+
if (chain.heldTimer !== undefined) clearTimeout(chain.heldTimer);
|
|
232
|
+
chain.heldTimer = undefined;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (chain.heldTimer !== undefined) return;
|
|
236
|
+
chain.heldWaitMs = Math.min(HELD_MOST_MS, chain.heldWaitMs * 2 || HELD_FIRST_MS);
|
|
237
|
+
chain.heldTimer = setTimeout(() => {
|
|
238
|
+
chain.heldTimer = undefined;
|
|
239
|
+
read();
|
|
240
|
+
}, chain.heldWaitMs);
|
|
241
|
+
chain.heldTimer.unref?.();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** One publish: the event of the writes that ended since the last publish, and of a read again. */
|
|
191
245
|
async function publish(chain: ChangeChain, storage: ChangeLog, bus: EventBus, topic: string, values: boolean, admission: CollectionAdmission | undefined): Promise<void> {
|
|
192
246
|
const admit = admission?.admit;
|
|
193
247
|
const writes = chain.ended.splice(0);
|
|
248
|
+
const owed = chain.owed;
|
|
249
|
+
chain.owed = false;
|
|
194
250
|
// An earlier publish told these writes
|
|
195
|
-
if (writes.length === 0) return;
|
|
251
|
+
if (writes.length === 0 && !owed) return;
|
|
252
|
+
const readAgain = (): void => rereadChain(chain, storage, bus, topic, values, admission);
|
|
196
253
|
if (!bus.hasSubscribers(topic)) {
|
|
197
254
|
// No one listens: the procedures do not read the log. The next call of `changes` starts the chain again.
|
|
198
255
|
chain.anchor = undefined;
|
|
256
|
+
whileHeld(chain, false, readAgain);
|
|
199
257
|
return;
|
|
200
258
|
}
|
|
201
259
|
const since = chain.anchor;
|
|
202
260
|
if (!storage.getChangesSince || since === undefined) {
|
|
203
|
-
bus.emit(topic, hint(undefined, originOf(writes, undefined)));
|
|
261
|
+
if (writes.length > 0) bus.emit(topic, hint(undefined, originOf(writes, undefined)));
|
|
204
262
|
return;
|
|
205
263
|
}
|
|
206
264
|
let changes: StorageChanges<unknown>;
|
|
@@ -208,16 +266,21 @@ async function publish(chain: ChangeChain, storage: ChangeLog, bus: EventBus, to
|
|
|
208
266
|
changes = await storage.getChangesSince(since);
|
|
209
267
|
} catch {
|
|
210
268
|
// The subscribers read the changes themselves. The chain keeps its version.
|
|
211
|
-
bus.emit(topic, hint(undefined, originOf(writes, undefined)));
|
|
269
|
+
if (writes.length > 0) bus.emit(topic, hint(undefined, originOf(writes, undefined)));
|
|
270
|
+
// A read again that failed told no subscriber, so it stays owed
|
|
271
|
+
else whileHeld(chain, true, readAgain);
|
|
212
272
|
return;
|
|
213
273
|
}
|
|
214
|
-
chain.
|
|
274
|
+
whileHeld(chain, changes.held === true, readAgain);
|
|
215
275
|
// A full result: the log does not know the version of the chain any more
|
|
216
276
|
if (changes.full || writes.some((write) => !write.anchored)) {
|
|
277
|
+
chain.anchor = changes.version;
|
|
217
278
|
bus.emit(topic, hint(changes.version, originOf(writes, undefined)));
|
|
218
279
|
return;
|
|
219
280
|
}
|
|
281
|
+
// No event tells this version, so the chain stays at the version of the last event. The next read from it gets each change.
|
|
220
282
|
if (changes.changed.length === 0 && changes.deleted.length === 0) return;
|
|
283
|
+
chain.anchor = changes.version;
|
|
221
284
|
const origin = originOf(writes, changes);
|
|
222
285
|
const event: CollectionChangeEvent = {
|
|
223
286
|
since,
|
|
@@ -231,6 +294,12 @@ async function publish(chain: ChangeChain, storage: ChangeLog, bus: EventBus, to
|
|
|
231
294
|
bus.emit(topic, admit ? withHintImages(out, { after: afterOf(changes), before: beforeOf(writes), admission: admission! }) : out);
|
|
232
295
|
}
|
|
233
296
|
|
|
297
|
+
/** This function reads the log of a chain again. A publish that did not start does this read. */
|
|
298
|
+
function rereadChain(chain: ChangeChain, storage: ChangeLog, bus: EventBus, topic: string, values: boolean, admission: CollectionAdmission | undefined): void {
|
|
299
|
+
chain.owed = true;
|
|
300
|
+
void nextPublish(chain, () => publish(chain, storage, bus, topic, values, admission));
|
|
301
|
+
}
|
|
302
|
+
|
|
234
303
|
/** The rows after the changes, from the change log: a changed id has its item, a deleted id has none. */
|
|
235
304
|
function afterOf(changes: StorageChanges<unknown>): Map<string, HintRowState> {
|
|
236
305
|
const after = new Map<string, HintRowState>();
|
|
@@ -295,9 +364,7 @@ export async function writeWithChangeEvent<R>(
|
|
|
295
364
|
return await write();
|
|
296
365
|
} finally {
|
|
297
366
|
chain.ended.push(started);
|
|
298
|
-
|
|
299
|
-
chain.tail = run.catch(() => undefined);
|
|
300
|
-
await chain.tail;
|
|
367
|
+
await nextPublish(chain, () => publish(chain, storage, bus, topic, values, admission));
|
|
301
368
|
}
|
|
302
369
|
}
|
|
303
370
|
|
|
@@ -311,3 +378,14 @@ export function startChangeChain(bus: EventBus | undefined, storage: object, top
|
|
|
311
378
|
const chain = chainOf(storage, topic);
|
|
312
379
|
if (chain.anchor === undefined) chain.anchor = version;
|
|
313
380
|
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* This function makes the chain read the log again when a call of `changes` gets a version that a
|
|
384
|
+
* write in flight holds back. A write outside the procedures then reaches the subscribers too.
|
|
385
|
+
* With no bus, no topic or no subscriber, it does nothing.
|
|
386
|
+
*/
|
|
387
|
+
export function rereadWhileHeld(bus: EventBus | undefined, storage: object & ChangeLog, topic: string | undefined, values = false, admission?: CollectionAdmission): void {
|
|
388
|
+
if (bus === undefined || topic === undefined || !bus.hasSubscribers(topic)) return;
|
|
389
|
+
const chain = chainOf(storage, topic);
|
|
390
|
+
whileHeld(chain, true, () => rereadChain(chain, storage, bus, topic, values, admission));
|
|
391
|
+
}
|
|
@@ -58,7 +58,7 @@ import {
|
|
|
58
58
|
} from "./schemas.js";
|
|
59
59
|
import { DEFAULT_FIND_LIMIT, queryEntries, type StorageQueryOptions, type StorageQueryPolicy } from "./query.js";
|
|
60
60
|
import type { CollectionStorage, StorageBatchWriteOptions, StorageItemWriteOptions, StorageWriteOptions } from "./storage.js";
|
|
61
|
-
import { collectionChangeTopic, startChangeChain, writeWithChangeEvent } from "./changes.js";
|
|
61
|
+
import { collectionChangeTopic, rereadWhileHeld, startChangeChain, writeWithChangeEvent } from "./changes.js";
|
|
62
62
|
import { partitionOfCall, storageOfCall, withPartition, type PartitionInput } from "./partition.js";
|
|
63
63
|
import { admitsRow, admittedStorage, checkAdmission, type CollectionAdmission } from "./admission.js";
|
|
64
64
|
import { patchItem } from "./patch-in-parts.js";
|
|
@@ -339,7 +339,12 @@ export function createCollectionProcedures(collectionName: string, options: Coll
|
|
|
339
339
|
const changes = await view.getChangesSince(input.since);
|
|
340
340
|
// The chain of the change events starts at this version: the caller then applies the
|
|
341
341
|
// next event without a read (roadmap CLI-15)
|
|
342
|
-
if (topicRule)
|
|
342
|
+
if (topicRule) {
|
|
343
|
+
const topic = topicOf(ctx, partition);
|
|
344
|
+
startChangeChain(ctx.bus, storage, topic, changes.version);
|
|
345
|
+
// A write in flight holds this read back, so the chain reads again until the write ends (roadmap CLI-102)
|
|
346
|
+
if (changes.held) rereadWhileHeld(ctx.bus, storage, topic, withValues, admission);
|
|
347
|
+
}
|
|
343
348
|
return {
|
|
344
349
|
version: changes.version,
|
|
345
350
|
full: changes.full,
|
|
@@ -185,6 +185,11 @@ export type StorageObserver<T> = (changes: StorageChangeBatch<T>) => void;
|
|
|
185
185
|
* has the ids that were deleted after it.
|
|
186
186
|
* - The order of a full result is the order of the entries. The order of a delta is the order of
|
|
187
187
|
* the last change of each id.
|
|
188
|
+
* - A result gives each write at least one time. A write can come again in two cases, with the same
|
|
189
|
+
* value or a later value. A full result takes its version before it lists the items, so it can
|
|
190
|
+
* hold a write above that version, and the next delta gives the write again. A write that ends
|
|
191
|
+
* after its lease can come again in a later delta, with a new version. A consumer applies a change
|
|
192
|
+
* by its id, and does not count changes.
|
|
188
193
|
*/
|
|
189
194
|
export interface StorageChanges<T> {
|
|
190
195
|
/** The version of the storage that includes these changes. */
|
|
@@ -195,6 +200,11 @@ export interface StorageChanges<T> {
|
|
|
195
200
|
changed: Array<[string, T]>;
|
|
196
201
|
/** The ids that were deleted. Empty when `full` is true. */
|
|
197
202
|
deleted: string[];
|
|
203
|
+
/**
|
|
204
|
+
* True when this version stops below a write in flight. A later read can give more changes, with no
|
|
205
|
+
* new write.
|
|
206
|
+
*/
|
|
207
|
+
held?: boolean;
|
|
198
208
|
}
|
|
199
209
|
|
|
200
210
|
/** The metadata that a storage gives about itself. */
|
|
@@ -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) {
|