y-rb 0.3.2-x64-mingw32
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/ext/yrb/Cargo.toml +21 -0
- data/ext/yrb/extconf.rb +6 -0
- data/ext/yrb/src/awareness.rs +425 -0
- data/ext/yrb/src/lib.rs +505 -0
- data/ext/yrb/src/utils.rs +34 -0
- data/ext/yrb/src/yany.rs +44 -0
- data/ext/yrb/src/yarray.rs +148 -0
- data/ext/yrb/src/yattrs.rs +48 -0
- data/ext/yrb/src/yawareness.rs +131 -0
- data/ext/yrb/src/ydoc.rs +35 -0
- data/ext/yrb/src/ymap.rs +151 -0
- data/ext/yrb/src/ytext.rs +204 -0
- data/ext/yrb/src/ytransaction.rs +55 -0
- data/ext/yrb/src/yvalue.rs +231 -0
- data/ext/yrb/src/yxml_element.rs +212 -0
- data/ext/yrb/src/yxml_text.rs +136 -0
- data/lib/2.7/yrb.so +0 -0
- data/lib/3.0/yrb.so +0 -0
- data/lib/y/array.rb +354 -0
- data/lib/y/awareness.rb +266 -0
- data/lib/y/doc.rb +220 -0
- data/lib/y/map.rb +202 -0
- data/lib/y/text.rb +372 -0
- data/lib/y/transaction.rb +143 -0
- data/lib/y/version.rb +5 -0
- data/lib/y/xml.rb +870 -0
- data/lib/y-rb.rb +23 -0
- data/lib/y.rb +3 -0
- metadata +136 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 1f36fedaf73d9ee6ca57cdffd3aaa8ab929125ddcb02dbf4f2f8ef9d955fc3ff
|
4
|
+
data.tar.gz: 33ee25caaad2fba8502209f1b9235c910fb9824c2dbd02fff3de0fee1370d07b
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 0e2731b2d8ccf556eb1b326102dd91f72b607bb952eb90f64b120d5cab753803a9670733d8ce1d6f6eb0e96e49492200588ae85877a955193b2b114be2710604
|
7
|
+
data.tar.gz: ebc4756e03e9757e2e38269f393733fe6852e62939636b28adc4091912f8a320b95f811c57c6da05993b46f11e810bdf74c43fc4aca485108fb1e9325a69d3f6
|
data/ext/yrb/Cargo.toml
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
[package]
|
2
|
+
name = "yrb"
|
3
|
+
version = "0.3.2"
|
4
|
+
authors = ["Hannes Moser <box@hannesmoser.at>", "Hannes Moser <hmoser@gitlab.com>"]
|
5
|
+
edition = "2021"
|
6
|
+
homepage = "https://github.com/y-crdt/yrb"
|
7
|
+
repository = "https://github.com/y-crdt/yrb"
|
8
|
+
|
9
|
+
[dependencies]
|
10
|
+
lib0 = "0.12.2" # must match yrs version
|
11
|
+
magnus = { git = "https://github.com/matsadler/magnus", rev = "4c9857cecabec2df6cc230763560815520de20b7"} # waiting for release with full rb-sys backend
|
12
|
+
rb-sys = { version = "~0.9.35", features = ["link-ruby"] }
|
13
|
+
thiserror = "1.0.37"
|
14
|
+
yrs = "0.12.2"
|
15
|
+
|
16
|
+
[dev-dependencies]
|
17
|
+
magnus = { git = "https://github.com/matsadler/magnus", rev = "4c9857cecabec2df6cc230763560815520de20b7", features = ["embed"] } # waiting for release with full rb-sys backend
|
18
|
+
|
19
|
+
[lib]
|
20
|
+
name = "yrb"
|
21
|
+
crate-type = ["cdylib"]
|
data/ext/yrb/extconf.rb
ADDED
@@ -0,0 +1,425 @@
|
|
1
|
+
use std::cell::RefCell;
|
2
|
+
use std::collections::hash_map::Entry;
|
3
|
+
use std::collections::HashMap;
|
4
|
+
use std::rc::{Rc, Weak};
|
5
|
+
use std::time::Instant;
|
6
|
+
use thiserror::Error;
|
7
|
+
use yrs::block::ClientID;
|
8
|
+
use yrs::updates::decoder::{Decode, Decoder};
|
9
|
+
use yrs::updates::encoder::{Encode, Encoder};
|
10
|
+
use yrs::{Doc, SubscriptionId};
|
11
|
+
|
12
|
+
const NULL_STR: &str = "null";
|
13
|
+
|
14
|
+
/// The Awareness class implements a simple shared state protocol that can be used for non-persistent
|
15
|
+
/// data like awareness information (cursor, username, status, ..). Each client can update its own
|
16
|
+
/// local state and listen to state changes of remote clients.
|
17
|
+
///
|
18
|
+
/// Each client is identified by a unique client id (something we borrow from `doc.clientID`).
|
19
|
+
/// A client can override its own state by propagating a message with an increasing timestamp
|
20
|
+
/// (`clock`). If such a message is received, it is applied if the known state of that client is
|
21
|
+
/// older than the new state (`clock < new_clock`). If a client thinks that a remote client is
|
22
|
+
/// offline, it may propagate a message with `{ clock, state: null, client }`. If such a message is
|
23
|
+
/// received, and the known clock of that client equals the received clock, it will clean the state.
|
24
|
+
///
|
25
|
+
/// Before a client disconnects, it should propagate a `null` state with an updated clock.
|
26
|
+
pub struct Awareness {
|
27
|
+
doc: Doc,
|
28
|
+
states: HashMap<ClientID, String>,
|
29
|
+
meta: HashMap<ClientID, MetaClientState>,
|
30
|
+
on_update: Option<EventHandler<Event>>,
|
31
|
+
}
|
32
|
+
|
33
|
+
unsafe impl Send for Awareness {}
|
34
|
+
unsafe impl Sync for Awareness {}
|
35
|
+
|
36
|
+
impl Awareness {
|
37
|
+
/// Creates a new instance of [Awareness] struct, which operates over a given document.
|
38
|
+
/// Awareness instance has full ownership of that document. If necessary it can be accessed
|
39
|
+
/// using either [Awareness::doc] or [Awareness::doc_mut] methods.
|
40
|
+
pub fn new(doc: Doc) -> Self {
|
41
|
+
Awareness {
|
42
|
+
doc,
|
43
|
+
on_update: None,
|
44
|
+
states: HashMap::new(),
|
45
|
+
meta: HashMap::new(),
|
46
|
+
}
|
47
|
+
}
|
48
|
+
|
49
|
+
/// Returns a channel receiver for an incoming awareness events. This channel can be cloned.
|
50
|
+
pub fn on_update<F>(&mut self, f: F) -> Subscription<Event>
|
51
|
+
where
|
52
|
+
F: Fn(&Awareness, &Event) + 'static,
|
53
|
+
{
|
54
|
+
let eh = self.on_update.get_or_insert_with(EventHandler::default);
|
55
|
+
eh.subscribe(f)
|
56
|
+
}
|
57
|
+
|
58
|
+
/// Removes a receiver for incoming awareness events.
|
59
|
+
pub fn remove_on_update(&mut self, subscription_id: u32) {
|
60
|
+
if let Some(eh) = self.on_update.as_mut() {
|
61
|
+
eh.unsubscribe(subscription_id);
|
62
|
+
}
|
63
|
+
}
|
64
|
+
|
65
|
+
/// Returns a globally unique client ID of an underlying [Doc].
|
66
|
+
pub fn client_id(&self) -> ClientID {
|
67
|
+
self.doc.client_id
|
68
|
+
}
|
69
|
+
|
70
|
+
/// Returns a state map of all of the clients tracked by current [Awareness] instance. Those
|
71
|
+
/// states are identified by their corresponding [ClientID]s. The associated state is
|
72
|
+
/// represented and replicated to other clients as a JSON string.
|
73
|
+
pub fn clients(&self) -> &HashMap<ClientID, String> {
|
74
|
+
&self.states
|
75
|
+
}
|
76
|
+
|
77
|
+
/// Returns a JSON string state representation of a current [Awareness] instance.
|
78
|
+
pub fn local_state(&self) -> Option<&str> {
|
79
|
+
Some(self.states.get(&self.doc.client_id)?.as_str())
|
80
|
+
}
|
81
|
+
|
82
|
+
/// Sets a current [Awareness] instance state to a corresponding JSON string. This state will
|
83
|
+
/// be replicated to other clients as part of the [AwarenessUpdate] and it will trigger an event
|
84
|
+
/// to be emitted if current instance was created using [Awareness::with_observer] method.
|
85
|
+
///
|
86
|
+
pub fn set_local_state<S: Into<String>>(&mut self, json: S) {
|
87
|
+
let client_id = self.doc.client_id;
|
88
|
+
self.update_meta(client_id);
|
89
|
+
let new: String = json.into();
|
90
|
+
match self.states.entry(client_id) {
|
91
|
+
Entry::Occupied(mut e) => {
|
92
|
+
e.insert(new);
|
93
|
+
if let Some(eh) = self.on_update.as_ref() {
|
94
|
+
eh.trigger(self, &Event::new(vec![], vec![client_id], vec![]));
|
95
|
+
}
|
96
|
+
}
|
97
|
+
Entry::Vacant(e) => {
|
98
|
+
e.insert(new);
|
99
|
+
if let Some(eh) = self.on_update.as_ref() {
|
100
|
+
eh.trigger(self, &Event::new(vec![client_id], vec![], vec![]));
|
101
|
+
}
|
102
|
+
}
|
103
|
+
}
|
104
|
+
}
|
105
|
+
|
106
|
+
/// Clears out a state of a given client, effectively marking it as disconnected.
|
107
|
+
pub fn remove_state(&mut self, client_id: ClientID) {
|
108
|
+
let prev_state = self.states.remove(&client_id);
|
109
|
+
self.update_meta(client_id);
|
110
|
+
if let Some(eh) = self.on_update.as_ref() {
|
111
|
+
if prev_state.is_some() {
|
112
|
+
eh.trigger(
|
113
|
+
self,
|
114
|
+
&Event::new(Vec::default(), Vec::default(), vec![client_id]),
|
115
|
+
);
|
116
|
+
}
|
117
|
+
}
|
118
|
+
}
|
119
|
+
|
120
|
+
/// Clears out a state of a current client (see: [Awareness::client_id]),
|
121
|
+
/// effectively marking it as disconnected.
|
122
|
+
pub fn clean_local_state(&mut self) {
|
123
|
+
let client_id = self.doc.client_id;
|
124
|
+
self.remove_state(client_id);
|
125
|
+
}
|
126
|
+
|
127
|
+
fn update_meta(&mut self, client_id: ClientID) {
|
128
|
+
match self.meta.entry(client_id) {
|
129
|
+
Entry::Occupied(mut e) => {
|
130
|
+
let clock = e.get().clock + 1;
|
131
|
+
let meta = MetaClientState::new(clock, Instant::now());
|
132
|
+
e.insert(meta);
|
133
|
+
}
|
134
|
+
Entry::Vacant(e) => {
|
135
|
+
e.insert(MetaClientState::new(1, Instant::now()));
|
136
|
+
}
|
137
|
+
}
|
138
|
+
}
|
139
|
+
|
140
|
+
/// Returns a serializable update object which is representation of a current Awareness state.
|
141
|
+
pub fn update(&self) -> Result<AwarenessUpdate, Error> {
|
142
|
+
let clients = self.states.keys().cloned();
|
143
|
+
self.update_with_clients(clients)
|
144
|
+
}
|
145
|
+
|
146
|
+
/// Returns a serializable update object which is representation of a current Awareness state.
|
147
|
+
/// Unlike [Awareness::update], this method variant allows to prepare update only for a subset
|
148
|
+
/// of known clients. These clients must all be known to a current [Awareness] instance,
|
149
|
+
/// otherwise a [Error::ClientNotFound] error will be returned.
|
150
|
+
pub fn update_with_clients<I: IntoIterator<Item = ClientID>>(
|
151
|
+
&self,
|
152
|
+
clients: I,
|
153
|
+
) -> Result<AwarenessUpdate, Error> {
|
154
|
+
let mut res = HashMap::new();
|
155
|
+
for client_id in clients {
|
156
|
+
let clock = if let Some(meta) = self.meta.get(&client_id) {
|
157
|
+
meta.clock
|
158
|
+
} else {
|
159
|
+
return Err(Error::ClientNotFound(client_id));
|
160
|
+
};
|
161
|
+
let json = if let Some(json) = self.states.get(&client_id) {
|
162
|
+
json.clone()
|
163
|
+
} else {
|
164
|
+
String::from(NULL_STR)
|
165
|
+
};
|
166
|
+
res.insert(client_id, AwarenessUpdateEntry { clock, json });
|
167
|
+
}
|
168
|
+
Ok(AwarenessUpdate { clients: res })
|
169
|
+
}
|
170
|
+
|
171
|
+
/// Applies an update (incoming from remote channel or generated using [Awareness::update] /
|
172
|
+
/// [Awareness::update_with_clients] methods) and modifies a state of a current instance.
|
173
|
+
///
|
174
|
+
/// If current instance has an observer channel (see: [Awareness::with_observer]), applied
|
175
|
+
/// changes will also be emitted as events.
|
176
|
+
pub fn apply_update(&mut self, update: AwarenessUpdate) -> Result<(), Error> {
|
177
|
+
let now = Instant::now();
|
178
|
+
|
179
|
+
let mut added = Vec::new();
|
180
|
+
let mut updated = Vec::new();
|
181
|
+
let mut removed = Vec::new();
|
182
|
+
|
183
|
+
for (client_id, entry) in update.clients {
|
184
|
+
let mut clock = entry.clock;
|
185
|
+
let is_null = entry.json.as_str() == NULL_STR;
|
186
|
+
match self.meta.entry(client_id) {
|
187
|
+
Entry::Occupied(mut e) => {
|
188
|
+
let prev = e.get();
|
189
|
+
let is_removed =
|
190
|
+
prev.clock == clock && is_null && self.states.contains_key(&client_id);
|
191
|
+
let is_new = prev.clock < clock;
|
192
|
+
if is_new || is_removed {
|
193
|
+
if is_null {
|
194
|
+
// never let a remote client remove this local state
|
195
|
+
if client_id == self.doc.client_id
|
196
|
+
&& self.states.get(&client_id).is_some()
|
197
|
+
{
|
198
|
+
// remote client removed the local state. Do not remote state. Broadcast a message indicating
|
199
|
+
// that this client still exists by increasing the clock
|
200
|
+
clock += 1;
|
201
|
+
} else {
|
202
|
+
self.states.remove(&client_id);
|
203
|
+
if self.on_update.is_some() {
|
204
|
+
removed.push(client_id);
|
205
|
+
}
|
206
|
+
}
|
207
|
+
} else {
|
208
|
+
match self.states.entry(client_id) {
|
209
|
+
Entry::Occupied(mut e) => {
|
210
|
+
if self.on_update.is_some() {
|
211
|
+
updated.push(client_id);
|
212
|
+
}
|
213
|
+
e.insert(entry.json);
|
214
|
+
}
|
215
|
+
Entry::Vacant(e) => {
|
216
|
+
e.insert(entry.json);
|
217
|
+
if self.on_update.is_some() {
|
218
|
+
updated.push(client_id);
|
219
|
+
}
|
220
|
+
}
|
221
|
+
}
|
222
|
+
}
|
223
|
+
e.insert(MetaClientState::new(clock, now));
|
224
|
+
true
|
225
|
+
} else {
|
226
|
+
false
|
227
|
+
}
|
228
|
+
}
|
229
|
+
Entry::Vacant(e) => {
|
230
|
+
e.insert(MetaClientState::new(clock, now));
|
231
|
+
self.states.insert(client_id, entry.json);
|
232
|
+
if self.on_update.is_some() {
|
233
|
+
added.push(client_id);
|
234
|
+
}
|
235
|
+
true
|
236
|
+
}
|
237
|
+
};
|
238
|
+
}
|
239
|
+
|
240
|
+
if let Some(eh) = self.on_update.as_ref() {
|
241
|
+
if !added.is_empty() || !updated.is_empty() || !removed.is_empty() {
|
242
|
+
eh.trigger(self, &Event::new(added, updated, removed));
|
243
|
+
}
|
244
|
+
}
|
245
|
+
|
246
|
+
Ok(())
|
247
|
+
}
|
248
|
+
}
|
249
|
+
|
250
|
+
impl Default for Awareness {
|
251
|
+
fn default() -> Self {
|
252
|
+
Awareness::new(Doc::new())
|
253
|
+
}
|
254
|
+
}
|
255
|
+
|
256
|
+
#[allow(clippy::type_complexity)]
|
257
|
+
struct EventHandler<T> {
|
258
|
+
seq_nr: u32,
|
259
|
+
subscribers: Rc<RefCell<HashMap<u32, Box<dyn Fn(&Awareness, &T)>>>>,
|
260
|
+
}
|
261
|
+
|
262
|
+
impl<T> EventHandler<T> {
|
263
|
+
pub fn subscribe<F>(&mut self, f: F) -> Subscription<T>
|
264
|
+
where
|
265
|
+
F: Fn(&Awareness, &T) + 'static,
|
266
|
+
{
|
267
|
+
let subscription_id = self.seq_nr;
|
268
|
+
self.seq_nr += 1;
|
269
|
+
{
|
270
|
+
let func = Box::new(f);
|
271
|
+
let mut subs = self.subscribers.borrow_mut();
|
272
|
+
subs.insert(subscription_id, func);
|
273
|
+
}
|
274
|
+
Subscription {
|
275
|
+
subscription_id,
|
276
|
+
subscribers: Rc::downgrade(&self.subscribers),
|
277
|
+
}
|
278
|
+
}
|
279
|
+
|
280
|
+
pub fn unsubscribe(&mut self, subscription_id: u32) {
|
281
|
+
let mut subs = self.subscribers.borrow_mut();
|
282
|
+
subs.remove(&subscription_id);
|
283
|
+
}
|
284
|
+
|
285
|
+
pub fn trigger(&self, awareness: &Awareness, arg: &T) {
|
286
|
+
let subs = self.subscribers.borrow();
|
287
|
+
for func in subs.values() {
|
288
|
+
func(awareness, arg);
|
289
|
+
}
|
290
|
+
}
|
291
|
+
}
|
292
|
+
|
293
|
+
impl<T> Default for EventHandler<T> {
|
294
|
+
fn default() -> Self {
|
295
|
+
EventHandler {
|
296
|
+
seq_nr: 0,
|
297
|
+
subscribers: Rc::new(RefCell::new(HashMap::new())),
|
298
|
+
}
|
299
|
+
}
|
300
|
+
}
|
301
|
+
|
302
|
+
/// Whenever a new callback is being registered, a [Subscription] is made. Whenever this
|
303
|
+
/// subscription a registered callback is cancelled and will not be called any more.
|
304
|
+
#[allow(clippy::type_complexity)]
|
305
|
+
pub struct Subscription<T> {
|
306
|
+
subscription_id: u32,
|
307
|
+
subscribers: Weak<RefCell<HashMap<u32, Box<dyn Fn(&Awareness, &T)>>>>,
|
308
|
+
}
|
309
|
+
|
310
|
+
#[allow(clippy::from_over_into)]
|
311
|
+
impl<T> Into<SubscriptionId> for Subscription<T> {
|
312
|
+
fn into(self) -> SubscriptionId {
|
313
|
+
let id = self.subscription_id;
|
314
|
+
std::mem::forget(self);
|
315
|
+
id
|
316
|
+
}
|
317
|
+
}
|
318
|
+
|
319
|
+
impl<T> Drop for Subscription<T> {
|
320
|
+
fn drop(&mut self) {
|
321
|
+
if let Some(subs) = self.subscribers.upgrade() {
|
322
|
+
let mut s = subs.borrow_mut();
|
323
|
+
s.remove(&self.subscription_id);
|
324
|
+
}
|
325
|
+
}
|
326
|
+
}
|
327
|
+
|
328
|
+
/// A structure that represents an encodable state of an [Awareness] struct.
|
329
|
+
#[derive(Debug, Eq, PartialEq)]
|
330
|
+
pub struct AwarenessUpdate {
|
331
|
+
clients: HashMap<ClientID, AwarenessUpdateEntry>,
|
332
|
+
}
|
333
|
+
|
334
|
+
impl Encode for AwarenessUpdate {
|
335
|
+
fn encode<E: Encoder>(&self, encoder: &mut E) {
|
336
|
+
encoder.write_var(self.clients.len());
|
337
|
+
for (&client_id, e) in self.clients.iter() {
|
338
|
+
encoder.write_var(client_id);
|
339
|
+
encoder.write_var(e.clock);
|
340
|
+
encoder.write_string(&e.json);
|
341
|
+
}
|
342
|
+
}
|
343
|
+
}
|
344
|
+
|
345
|
+
impl Decode for AwarenessUpdate {
|
346
|
+
fn decode<D: Decoder>(decoder: &mut D) -> Result<Self, lib0::error::Error> {
|
347
|
+
let len: usize = decoder.read_var()?;
|
348
|
+
let mut clients = HashMap::with_capacity(len);
|
349
|
+
for _ in 0..len {
|
350
|
+
let client_id: ClientID = decoder.read_var()?;
|
351
|
+
let clock: u32 = decoder.read_var()?;
|
352
|
+
let json = decoder.read_string()?.to_string();
|
353
|
+
clients.insert(client_id, AwarenessUpdateEntry { clock, json });
|
354
|
+
}
|
355
|
+
|
356
|
+
Ok(AwarenessUpdate { clients })
|
357
|
+
}
|
358
|
+
}
|
359
|
+
|
360
|
+
/// A single client entry of an [AwarenessUpdate]. It consists of logical clock and JSON client
|
361
|
+
/// state represented as a string.
|
362
|
+
#[derive(Debug, Eq, PartialEq)]
|
363
|
+
pub struct AwarenessUpdateEntry {
|
364
|
+
clock: u32,
|
365
|
+
json: String,
|
366
|
+
}
|
367
|
+
|
368
|
+
/// Errors generated by an [Awareness] struct methods.
|
369
|
+
#[derive(Error, Debug)]
|
370
|
+
pub enum Error {
|
371
|
+
/// Client ID was not found in [Awareness] metadata.
|
372
|
+
#[error("client ID `{0}` not found")]
|
373
|
+
ClientNotFound(ClientID),
|
374
|
+
}
|
375
|
+
|
376
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
377
|
+
struct MetaClientState {
|
378
|
+
clock: u32,
|
379
|
+
last_updated: Instant,
|
380
|
+
}
|
381
|
+
|
382
|
+
impl MetaClientState {
|
383
|
+
fn new(clock: u32, last_updated: Instant) -> Self {
|
384
|
+
MetaClientState {
|
385
|
+
clock,
|
386
|
+
last_updated,
|
387
|
+
}
|
388
|
+
}
|
389
|
+
}
|
390
|
+
|
391
|
+
/// Event type emitted by an [Awareness] struct.
|
392
|
+
#[derive(Debug, Default, Clone, Eq, PartialEq)]
|
393
|
+
pub struct Event {
|
394
|
+
added: Vec<ClientID>,
|
395
|
+
updated: Vec<ClientID>,
|
396
|
+
removed: Vec<ClientID>,
|
397
|
+
}
|
398
|
+
|
399
|
+
impl Event {
|
400
|
+
pub fn new(added: Vec<ClientID>, updated: Vec<ClientID>, removed: Vec<ClientID>) -> Self {
|
401
|
+
Event {
|
402
|
+
added,
|
403
|
+
updated,
|
404
|
+
removed,
|
405
|
+
}
|
406
|
+
}
|
407
|
+
|
408
|
+
/// Collection of new clients that have been added to an [Awareness] struct, that was not known
|
409
|
+
/// before. Actual client state can be accessed via `awareness.clients().get(client_id)`.
|
410
|
+
pub fn added(&self) -> &[ClientID] {
|
411
|
+
&self.added
|
412
|
+
}
|
413
|
+
|
414
|
+
/// Collection of new clients that have been updated within an [Awareness] struct since the last
|
415
|
+
/// update. Actual client state can be accessed via `awareness.clients().get(client_id)`.
|
416
|
+
pub fn updated(&self) -> &[ClientID] {
|
417
|
+
&self.updated
|
418
|
+
}
|
419
|
+
|
420
|
+
/// Collection of new clients that have been removed from [Awareness] struct since the last
|
421
|
+
/// update.
|
422
|
+
pub fn removed(&self) -> &[ClientID] {
|
423
|
+
&self.removed
|
424
|
+
}
|
425
|
+
}
|