polfan-server-js-client 0.3.2 → 0.3.3
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/.idea/workspace.xml +45 -53
- package/build/index.cjs.js +414 -211
- package/build/index.cjs.js.map +1 -1
- package/build/index.umd.js +1 -1
- package/build/index.umd.js.map +1 -1
- package/build/types/state-tracker/RoomMessagesHistory.d.ts +15 -4
- package/build/types/state-tracker/TopicHistoryWindow.d.ts +48 -0
- package/package.json +1 -1
- package/src/state-tracker/RoomMessagesHistory.ts +27 -9
- package/src/state-tracker/TopicHistoryWindow.ts +133 -1
- package/tests/history-window.test.ts +131 -0
- package/tests/state-reconnect.test.ts +260 -0
|
@@ -6,6 +6,7 @@ export declare class RoomMessagesHistory {
|
|
|
6
6
|
private tracker;
|
|
7
7
|
private historyWindows;
|
|
8
8
|
private traverseLock;
|
|
9
|
+
private timeLimitedHistory;
|
|
9
10
|
constructor(room: Room, tracker: ChatStateTracker);
|
|
10
11
|
/**
|
|
11
12
|
* Returns a history window object for the given topic ID, allowing you to view message history.
|
|
@@ -20,14 +21,24 @@ export declare class RoomMessagesHistory {
|
|
|
20
21
|
*
|
|
21
22
|
* The window bindings are preserved; only windows that the application had
|
|
22
23
|
* actually pulled to the latest page (state === LATEST) are refreshed, with
|
|
23
|
-
* a single
|
|
24
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* a single request instead of a chain of catch-up requests. Windows that
|
|
25
|
+
* were never pulled (LIVE) or belong to an ephemeral room are left untouched
|
|
26
|
+
* so their in-memory context survives the reconnect.
|
|
27
|
+
*
|
|
28
|
+
* How a refreshed window is rebuilt depends on the room history mode: rooms
|
|
29
|
+
* keeping the full history are simply reset to the latest page (it can
|
|
30
|
+
* always be traversed back on demand), while rooms with a time-limited
|
|
31
|
+
* history (MaxAge) load the messages missed during the downtime on top of
|
|
32
|
+
* the already loaded ones, with the messages returned in both deduplicated.
|
|
33
|
+
* The maxAge retention applies to what the server serves - so that users
|
|
34
|
+
* joining later do not see the older conversation - and never to what this
|
|
35
|
+
* client already has: messages the user witnessed stay in the window until
|
|
36
|
+
* they are pushed out by its own size limit.
|
|
26
37
|
*/
|
|
27
38
|
resync(room: Room): Promise<void>;
|
|
28
39
|
private handleRoomUpdated;
|
|
29
40
|
private handleNewTopic;
|
|
30
41
|
private handleTopicDeleted;
|
|
31
42
|
private createHistoryWindowForTopic;
|
|
32
|
-
private
|
|
43
|
+
private updateHistoryMode;
|
|
33
44
|
}
|
|
@@ -34,6 +34,7 @@ export declare abstract class TraversableRemoteCollection<ItemT, EventMapT exten
|
|
|
34
34
|
fetchLimit: number;
|
|
35
35
|
lastFetchCount: number;
|
|
36
36
|
oldestId: string | null;
|
|
37
|
+
gaps: string[];
|
|
37
38
|
};
|
|
38
39
|
/**
|
|
39
40
|
* Number of items to fetch per request.
|
|
@@ -62,12 +63,44 @@ export declare abstract class TraversableRemoteCollection<ItemT, EventMapT exten
|
|
|
62
63
|
*/
|
|
63
64
|
set retainRatio(value: number);
|
|
64
65
|
get hasLatest(): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* IDs of the items the window could not stitch to the ones loaded before
|
|
68
|
+
* them: there is a gap in front of each of them, i.e. the item right above
|
|
69
|
+
* it in the window is not its real predecessor and an unknown number of
|
|
70
|
+
* items in between was never fetched.
|
|
71
|
+
*
|
|
72
|
+
* Such a gap appears when the collection is resynchronised after a
|
|
73
|
+
* reconnect (see resyncToLatest) and more items than a single page arrived
|
|
74
|
+
* while the connection was down. The markers are kept in the window order
|
|
75
|
+
* and disappear together with the items they point at.
|
|
76
|
+
*/
|
|
77
|
+
get gaps(): readonly string[];
|
|
65
78
|
get hasOldest(): boolean;
|
|
66
79
|
abstract createMirror(): TraversableRemoteCollection<ItemT, EventMapT>;
|
|
67
80
|
resetToLatest(force?: boolean): Promise<void>;
|
|
81
|
+
/**
|
|
82
|
+
* Refresh the window with the latest page, keeping the already loaded items
|
|
83
|
+
* instead of replacing them.
|
|
84
|
+
*
|
|
85
|
+
* This is the reconnect-friendly variant of resetToLatest: the items missed
|
|
86
|
+
* while the connection was down are pulled with a single request and merged
|
|
87
|
+
* on top of the loaded ones (items returned in both are deduplicated), so
|
|
88
|
+
* the context the application already had does not disappear. The window
|
|
89
|
+
* size limit is the only thing that pushes the oldest items out.
|
|
90
|
+
*
|
|
91
|
+
* An empty or partial page is not a reason to drop anything: it only means
|
|
92
|
+
* the collection has little (or nothing) left on the remote side, while the
|
|
93
|
+
* items loaded earlier are still valid. When the page is full and does not
|
|
94
|
+
* reach the loaded items, an unknown number of items in between was never
|
|
95
|
+
* fetched - both parts are still kept, and the seam between them is recorded
|
|
96
|
+
* in `gaps` so the application can show where the history is not continuous.
|
|
97
|
+
*/
|
|
98
|
+
resyncToLatest(): Promise<void>;
|
|
68
99
|
fetchPrevious(): Promise<void>;
|
|
69
100
|
fetchNext(): Promise<void>;
|
|
70
101
|
jumpTo(id: string): Promise<void>;
|
|
102
|
+
delete(...ids: string[]): void;
|
|
103
|
+
deleteAll(): void;
|
|
71
104
|
protected abstract fetchLatestItems(): Promise<ItemT[]>;
|
|
72
105
|
protected abstract fetchItemsBefore(): Promise<ItemT[] | null>;
|
|
73
106
|
protected abstract fetchItemsAfter(): Promise<ItemT[] | null>;
|
|
@@ -76,6 +109,20 @@ export declare abstract class TraversableRemoteCollection<ItemT, EventMapT exten
|
|
|
76
109
|
protected refreshFetchedState(): Promise<void>;
|
|
77
110
|
protected addItems(newItems: ItemT[], to: 'head' | 'tail'): void;
|
|
78
111
|
protected emitChangeWithDiff(itemChanged: boolean, originalState: WindowState): void;
|
|
112
|
+
/**
|
|
113
|
+
* Record that the history is not continuous in front of the given item.
|
|
114
|
+
*/
|
|
115
|
+
protected markGapBefore(id: string): void;
|
|
116
|
+
/**
|
|
117
|
+
* Forget the gap in front of the given item - the items before it are known
|
|
118
|
+
* to be its real predecessors now.
|
|
119
|
+
*/
|
|
120
|
+
protected clearGapBefore(id: string): void;
|
|
121
|
+
/**
|
|
122
|
+
* Forget the gap markers pointing at items that are no longer in the window
|
|
123
|
+
* (trimmed, deleted or replaced), so `gaps` never refers to nothing.
|
|
124
|
+
*/
|
|
125
|
+
protected dropDanglingGaps(): void;
|
|
79
126
|
/**
|
|
80
127
|
* Return array with messages trimmed using High/Low Watermark strategy.
|
|
81
128
|
*/
|
|
@@ -100,6 +147,7 @@ export declare class TopicHistoryWindow extends TraversableRemoteCollection<Mess
|
|
|
100
147
|
get isTraverseLocked(): boolean;
|
|
101
148
|
setTraverseLock(lock: boolean): Promise<void>;
|
|
102
149
|
resetToLatest(force?: boolean): Promise<void>;
|
|
150
|
+
resyncToLatest(): Promise<void>;
|
|
103
151
|
fetchNext(): Promise<void>;
|
|
104
152
|
fetchPrevious(): Promise<void>;
|
|
105
153
|
jumpTo(id: string): Promise<void>;
|
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@ import {TopicHistoryWindow, WindowState} from "./TopicHistoryWindow";
|
|
|
6
6
|
export class RoomMessagesHistory {
|
|
7
7
|
private historyWindows = new IndexedCollection<string, TopicHistoryWindow>();
|
|
8
8
|
private traverseLock: boolean = false;
|
|
9
|
+
private timeLimitedHistory: boolean = false;
|
|
9
10
|
|
|
10
11
|
public constructor(
|
|
11
12
|
private room: Room,
|
|
@@ -15,7 +16,7 @@ export class RoomMessagesHistory {
|
|
|
15
16
|
this.tracker.client.on('NewTopic', ev => this.handleNewTopic(ev));
|
|
16
17
|
this.tracker.client.on('TopicDeleted', ev => this.handleTopicDeleted(ev));
|
|
17
18
|
|
|
18
|
-
this.
|
|
19
|
+
this.updateHistoryMode(this.room);
|
|
19
20
|
|
|
20
21
|
if (this.room.defaultTopic) {
|
|
21
22
|
this.createHistoryWindowForTopic(this.room.defaultTopic);
|
|
@@ -46,13 +47,23 @@ export class RoomMessagesHistory {
|
|
|
46
47
|
*
|
|
47
48
|
* The window bindings are preserved; only windows that the application had
|
|
48
49
|
* actually pulled to the latest page (state === LATEST) are refreshed, with
|
|
49
|
-
* a single
|
|
50
|
-
*
|
|
51
|
-
*
|
|
50
|
+
* a single request instead of a chain of catch-up requests. Windows that
|
|
51
|
+
* were never pulled (LIVE) or belong to an ephemeral room are left untouched
|
|
52
|
+
* so their in-memory context survives the reconnect.
|
|
53
|
+
*
|
|
54
|
+
* How a refreshed window is rebuilt depends on the room history mode: rooms
|
|
55
|
+
* keeping the full history are simply reset to the latest page (it can
|
|
56
|
+
* always be traversed back on demand), while rooms with a time-limited
|
|
57
|
+
* history (MaxAge) load the messages missed during the downtime on top of
|
|
58
|
+
* the already loaded ones, with the messages returned in both deduplicated.
|
|
59
|
+
* The maxAge retention applies to what the server serves - so that users
|
|
60
|
+
* joining later do not see the older conversation - and never to what this
|
|
61
|
+
* client already has: messages the user witnessed stay in the window until
|
|
62
|
+
* they are pushed out by its own size limit.
|
|
52
63
|
*/
|
|
53
64
|
public async resync(room: Room): Promise<void> {
|
|
54
65
|
this.room = room;
|
|
55
|
-
this.
|
|
66
|
+
this.updateHistoryMode(room);
|
|
56
67
|
|
|
57
68
|
if (this.room.defaultTopic) {
|
|
58
69
|
this.createHistoryWindowForTopic(this.room.defaultTopic);
|
|
@@ -68,7 +79,13 @@ export class RoomMessagesHistory {
|
|
|
68
79
|
continue;
|
|
69
80
|
}
|
|
70
81
|
|
|
71
|
-
if (window.state
|
|
82
|
+
if (window.state !== WindowState.LATEST) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (this.timeLimitedHistory) {
|
|
87
|
+
await window.resyncToLatest();
|
|
88
|
+
} else {
|
|
72
89
|
await window.resetToLatest(true);
|
|
73
90
|
}
|
|
74
91
|
} catch (_e) {
|
|
@@ -83,7 +100,7 @@ export class RoomMessagesHistory {
|
|
|
83
100
|
if (this.room.id === ev.room.id) {
|
|
84
101
|
this.room = ev.room;
|
|
85
102
|
|
|
86
|
-
this.
|
|
103
|
+
this.updateHistoryMode(ev.room);
|
|
87
104
|
|
|
88
105
|
if (ev.room.defaultTopic) {
|
|
89
106
|
this.createHistoryWindowForTopic(ev.room.defaultTopic);
|
|
@@ -134,7 +151,8 @@ export class RoomMessagesHistory {
|
|
|
134
151
|
}
|
|
135
152
|
}
|
|
136
153
|
|
|
137
|
-
private
|
|
138
|
-
this.traverseLock = room.history
|
|
154
|
+
private updateHistoryMode(room: Room): void {
|
|
155
|
+
this.traverseLock = room.history?.mode === 'Ephemeral';
|
|
156
|
+
this.timeLimitedHistory = room.history?.mode === 'MaxAge';
|
|
139
157
|
}
|
|
140
158
|
}
|
|
@@ -45,6 +45,7 @@ export abstract class TraversableRemoteCollection<
|
|
|
45
45
|
fetchLimit: number,
|
|
46
46
|
lastFetchCount: number,
|
|
47
47
|
oldestId: string | null,
|
|
48
|
+
gaps: string[],
|
|
48
49
|
} = {
|
|
49
50
|
current: WindowState.LIVE,
|
|
50
51
|
ongoing: undefined,
|
|
@@ -53,6 +54,7 @@ export abstract class TraversableRemoteCollection<
|
|
|
53
54
|
fetchLimit: 50,
|
|
54
55
|
lastFetchCount: 0,
|
|
55
56
|
oldestId: null,
|
|
57
|
+
gaps: [],
|
|
56
58
|
};
|
|
57
59
|
|
|
58
60
|
/**
|
|
@@ -103,6 +105,21 @@ export abstract class TraversableRemoteCollection<
|
|
|
103
105
|
return [WindowState.LATEST, WindowState.LIVE].includes(this.state);
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
/**
|
|
109
|
+
* IDs of the items the window could not stitch to the ones loaded before
|
|
110
|
+
* them: there is a gap in front of each of them, i.e. the item right above
|
|
111
|
+
* it in the window is not its real predecessor and an unknown number of
|
|
112
|
+
* items in between was never fetched.
|
|
113
|
+
*
|
|
114
|
+
* Such a gap appears when the collection is resynchronised after a
|
|
115
|
+
* reconnect (see resyncToLatest) and more items than a single page arrived
|
|
116
|
+
* while the connection was down. The markers are kept in the window order
|
|
117
|
+
* and disappear together with the items they point at.
|
|
118
|
+
*/
|
|
119
|
+
public get gaps(): readonly string[] {
|
|
120
|
+
return [...this.internalState.gaps];
|
|
121
|
+
}
|
|
122
|
+
|
|
106
123
|
public get hasOldest(): boolean {
|
|
107
124
|
return this.state === WindowState.OLDEST
|
|
108
125
|
|| this.state === WindowState.LATEST && this.length < this.fetchLimit
|
|
@@ -128,11 +145,68 @@ export abstract class TraversableRemoteCollection<
|
|
|
128
145
|
}
|
|
129
146
|
|
|
130
147
|
this._items.deleteAll(); // Directly call deleteAll to prevent event emit.
|
|
148
|
+
this.internalState.gaps = []; // Whole content is replaced, old markers mean nothing.
|
|
131
149
|
this.addItems(result, 'tail');
|
|
132
150
|
this.internalState.current = WindowState.LATEST;
|
|
133
151
|
this.emitChangeWithDiff(true, originalState);
|
|
134
152
|
}
|
|
135
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Refresh the window with the latest page, keeping the already loaded items
|
|
156
|
+
* instead of replacing them.
|
|
157
|
+
*
|
|
158
|
+
* This is the reconnect-friendly variant of resetToLatest: the items missed
|
|
159
|
+
* while the connection was down are pulled with a single request and merged
|
|
160
|
+
* on top of the loaded ones (items returned in both are deduplicated), so
|
|
161
|
+
* the context the application already had does not disappear. The window
|
|
162
|
+
* size limit is the only thing that pushes the oldest items out.
|
|
163
|
+
*
|
|
164
|
+
* An empty or partial page is not a reason to drop anything: it only means
|
|
165
|
+
* the collection has little (or nothing) left on the remote side, while the
|
|
166
|
+
* items loaded earlier are still valid. When the page is full and does not
|
|
167
|
+
* reach the loaded items, an unknown number of items in between was never
|
|
168
|
+
* fetched - both parts are still kept, and the seam between them is recorded
|
|
169
|
+
* in `gaps` so the application can show where the history is not continuous.
|
|
170
|
+
*/
|
|
171
|
+
public async resyncToLatest(): Promise<void> {
|
|
172
|
+
if (this.internalState.ongoing) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let result;
|
|
177
|
+
const originalState = this.state;
|
|
178
|
+
this.internalState.ongoing = WindowState.LATEST;
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
result = await this.fetchLatestItems();
|
|
182
|
+
this.internalState.lastFetchCount = result.length;
|
|
183
|
+
} finally {
|
|
184
|
+
this.internalState.ongoing = undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const loaded = this.items;
|
|
188
|
+
const fetchedIds = new Set(result.map(item => this.getId(item)));
|
|
189
|
+
// Items present in the page are taken from it - the server copy is the
|
|
190
|
+
// up-to-date one.
|
|
191
|
+
const retained = loaded.filter(item => ! fetchedIds.has(this.getId(item)));
|
|
192
|
+
|
|
193
|
+
// Nothing can be missing in between when there is nothing to stitch,
|
|
194
|
+
// when the page is everything the remote side has (it is shorter than
|
|
195
|
+
// the requested limit), or when the page reaches the newest loaded item.
|
|
196
|
+
const isContinuous = ! retained.length
|
|
197
|
+
|| result.length < this.internalState.fetchLimit
|
|
198
|
+
|| fetchedIds.has(this.getId(loaded[loaded.length - 1]));
|
|
199
|
+
|
|
200
|
+
if (! isContinuous) {
|
|
201
|
+
this.markGapBefore(this.getId(result[0]));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this._items.deleteAll(); // Directly call deleteAll to prevent event emit.
|
|
205
|
+
this.addItems([...retained, ...result], 'tail');
|
|
206
|
+
this.internalState.current = WindowState.LATEST;
|
|
207
|
+
this.emitChangeWithDiff(true, originalState);
|
|
208
|
+
}
|
|
209
|
+
|
|
136
210
|
public async fetchPrevious(): Promise<void> {
|
|
137
211
|
if (this.internalState.ongoing || this.hasOldest) {
|
|
138
212
|
return;
|
|
@@ -140,6 +214,7 @@ export abstract class TraversableRemoteCollection<
|
|
|
140
214
|
|
|
141
215
|
let result;
|
|
142
216
|
const originalState = this.state;
|
|
217
|
+
const firstItem = this.getAt(0);
|
|
143
218
|
this.internalState.ongoing = WindowState.PAST;
|
|
144
219
|
|
|
145
220
|
try {
|
|
@@ -154,7 +229,6 @@ export abstract class TraversableRemoteCollection<
|
|
|
154
229
|
}
|
|
155
230
|
|
|
156
231
|
if (! result.length) {
|
|
157
|
-
const firstItem = this.getAt(0);
|
|
158
232
|
this.internalState.oldestId = firstItem ? this.getId(firstItem) : null;
|
|
159
233
|
|
|
160
234
|
await this.refreshFetchedState();
|
|
@@ -168,6 +242,13 @@ export abstract class TraversableRemoteCollection<
|
|
|
168
242
|
return;
|
|
169
243
|
}
|
|
170
244
|
|
|
245
|
+
if (firstItem) {
|
|
246
|
+
// The fetch asked for the items right before the one that was first,
|
|
247
|
+
// so whatever came back is its real predecessor: a gap marked in
|
|
248
|
+
// front of it (it used to be the top of the window) is closed now.
|
|
249
|
+
this.clearGapBefore(this.getId(firstItem));
|
|
250
|
+
}
|
|
251
|
+
|
|
171
252
|
this.addItems(result, 'head');
|
|
172
253
|
await this.refreshFetchedState();
|
|
173
254
|
this.emitChangeWithDiff(true, originalState);
|
|
@@ -217,6 +298,7 @@ export abstract class TraversableRemoteCollection<
|
|
|
217
298
|
|
|
218
299
|
if (result) {
|
|
219
300
|
this._items.deleteAll(); // Directly call deleteAll to prevent event emit.
|
|
301
|
+
this.internalState.gaps = []; // Whole content is replaced, old markers mean nothing.
|
|
220
302
|
this.addItems(result, 'tail');
|
|
221
303
|
await this.refreshFetchedState();
|
|
222
304
|
}
|
|
@@ -227,6 +309,16 @@ export abstract class TraversableRemoteCollection<
|
|
|
227
309
|
this.emitChangeWithDiff(!!result, originalState);
|
|
228
310
|
}
|
|
229
311
|
|
|
312
|
+
public delete(...ids: string[]): void {
|
|
313
|
+
super.delete(...ids);
|
|
314
|
+
this.dropDanglingGaps();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
public deleteAll(): void {
|
|
318
|
+
super.deleteAll();
|
|
319
|
+
this.internalState.gaps = [];
|
|
320
|
+
}
|
|
321
|
+
|
|
230
322
|
protected abstract fetchLatestItems(): Promise<ItemT[]>;
|
|
231
323
|
|
|
232
324
|
protected abstract fetchItemsBefore(): Promise<ItemT[] | null>;
|
|
@@ -255,6 +347,8 @@ export abstract class TraversableRemoteCollection<
|
|
|
255
347
|
// Directly calls to prevent event emit.
|
|
256
348
|
this._items.deleteAll();
|
|
257
349
|
this._items.set(...(result.map(item => [this.getId(item), item] as [string, ItemT])));
|
|
350
|
+
|
|
351
|
+
this.dropDanglingGaps();
|
|
258
352
|
}
|
|
259
353
|
|
|
260
354
|
protected emitChangeWithDiff(itemChanged: boolean, originalState: WindowState): void {
|
|
@@ -263,6 +357,37 @@ export abstract class TraversableRemoteCollection<
|
|
|
263
357
|
}
|
|
264
358
|
}
|
|
265
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Record that the history is not continuous in front of the given item.
|
|
362
|
+
*/
|
|
363
|
+
protected markGapBefore(id: string): void {
|
|
364
|
+
if (! this.internalState.gaps.includes(id)) {
|
|
365
|
+
this.internalState.gaps = [...this.internalState.gaps, id];
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Forget the gap in front of the given item - the items before it are known
|
|
371
|
+
* to be its real predecessors now.
|
|
372
|
+
*/
|
|
373
|
+
protected clearGapBefore(id: string): void {
|
|
374
|
+
if (this.internalState.gaps.includes(id)) {
|
|
375
|
+
this.internalState.gaps = this.internalState.gaps.filter(gapId => gapId !== id);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Forget the gap markers pointing at items that are no longer in the window
|
|
381
|
+
* (trimmed, deleted or replaced), so `gaps` never refers to nothing.
|
|
382
|
+
*/
|
|
383
|
+
protected dropDanglingGaps(): void {
|
|
384
|
+
if (! this.internalState.gaps.length) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
this.internalState.gaps = this.internalState.gaps.filter(id => this.has(id));
|
|
389
|
+
}
|
|
390
|
+
|
|
266
391
|
/**
|
|
267
392
|
* Return array with messages trimmed using High/Low Watermark strategy.
|
|
268
393
|
*/
|
|
@@ -347,6 +472,13 @@ export class TopicHistoryWindow extends TraversableRemoteCollection<
|
|
|
347
472
|
return super.resetToLatest(force);
|
|
348
473
|
}
|
|
349
474
|
|
|
475
|
+
public async resyncToLatest(): Promise<void> {
|
|
476
|
+
if (this.internalState.traverseLock) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
return super.resyncToLatest();
|
|
480
|
+
}
|
|
481
|
+
|
|
350
482
|
public async fetchNext(): Promise<void> {
|
|
351
483
|
if (this.internalState.traverseLock) {
|
|
352
484
|
return;
|
|
@@ -209,6 +209,137 @@ test('history window - reset to latest', async () => {
|
|
|
209
209
|
[7,8,9].forEach(id => expect(window.items.map(item => item.id)).toContain(id));
|
|
210
210
|
});
|
|
211
211
|
|
|
212
|
+
test('history window - resync to latest merges the new page into loaded items', async () => {
|
|
213
|
+
const window = new TestableHistoryWindow();
|
|
214
|
+
window.limit = 10;
|
|
215
|
+
window.fetchLimit = 3;
|
|
216
|
+
|
|
217
|
+
await window.resetToLatest(); // [7,8,9]
|
|
218
|
+
await window.fetchPrevious(); // [4,5,6,7,8,9]
|
|
219
|
+
|
|
220
|
+
await window.resyncToLatest(); // fetched [7,8,9] overlaps -> nothing is lost
|
|
221
|
+
|
|
222
|
+
expect(window.state).toEqual(WindowState.LATEST);
|
|
223
|
+
expect(window.items.map(item => item.id)).toEqual([4, 5, 6, 7, 8, 9]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('history window - resync to latest trims the merged items to the limit', async () => {
|
|
227
|
+
const window = new TestableHistoryWindow();
|
|
228
|
+
window.limit = 4;
|
|
229
|
+
window.fetchLimit = 3;
|
|
230
|
+
|
|
231
|
+
await window.resetToLatest(); // [7,8,9]
|
|
232
|
+
await window.fetchPrevious(); // [4,5,6,7] (trimmed to the limit)
|
|
233
|
+
|
|
234
|
+
// [4,5,6,7] merged with the page [7,8,9] -> [4,5,6,7,8,9], trimmed again.
|
|
235
|
+
await window.resyncToLatest();
|
|
236
|
+
|
|
237
|
+
expect(window.state).toEqual(WindowState.LATEST);
|
|
238
|
+
expect(window.items.map(item => item.id)).toEqual([6, 7, 8, 9]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('history window - resync to latest marks the gap instead of dropping items', async () => {
|
|
242
|
+
const window = new TestableHistoryWindow();
|
|
243
|
+
window.limit = 10;
|
|
244
|
+
window.fetchLimit = 3;
|
|
245
|
+
|
|
246
|
+
await window.jumpTo('1'); // [0,1,2]
|
|
247
|
+
|
|
248
|
+
expect(window.gaps).toEqual([]);
|
|
249
|
+
|
|
250
|
+
// A full page ([7,8,9]) that does not reach the newest loaded item (2):
|
|
251
|
+
// items 3..6 were never fetched, so the seam in front of 7 is marked.
|
|
252
|
+
await window.resyncToLatest();
|
|
253
|
+
|
|
254
|
+
expect(window.state).toEqual(WindowState.LATEST);
|
|
255
|
+
expect(window.items.map(item => item.id)).toEqual([0, 1, 2, 7, 8, 9]);
|
|
256
|
+
expect(window.gaps).toEqual([7]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('history window - gap marker disappears with the item it points at', async () => {
|
|
260
|
+
const window = new TestableHistoryWindow();
|
|
261
|
+
window.limit = 10;
|
|
262
|
+
window.fetchLimit = 3;
|
|
263
|
+
|
|
264
|
+
await window.jumpTo('1'); // [0,1,2]
|
|
265
|
+
await window.resyncToLatest(); // [0,1,2] | [7,8,9]
|
|
266
|
+
|
|
267
|
+
expect(window.gaps).toEqual([7]);
|
|
268
|
+
|
|
269
|
+
window.delete(7);
|
|
270
|
+
|
|
271
|
+
expect(window.gaps).toEqual([]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('history window - gap marker is closed by fetching what is before it', async () => {
|
|
275
|
+
const window = new TestableHistoryWindow();
|
|
276
|
+
window.limit = 10;
|
|
277
|
+
window.fetchLimit = 3;
|
|
278
|
+
|
|
279
|
+
await window.jumpTo('1'); // [0,1,2]
|
|
280
|
+
await window.resyncToLatest(); // [0,1,2] | [7,8,9]
|
|
281
|
+
window.delete(0, 1, 2); // the items above the gap are gone, 7 is the top one
|
|
282
|
+
|
|
283
|
+
expect(window.gaps).toEqual([7]);
|
|
284
|
+
|
|
285
|
+
// Whatever comes back was fetched as the direct predecessor of 7, so the
|
|
286
|
+
// history is continuous again.
|
|
287
|
+
await window.fetchPrevious(); // [4,5,6,7,8,9]
|
|
288
|
+
|
|
289
|
+
expect(window.items.map(item => item.id)).toEqual([4, 5, 6, 7, 8, 9]);
|
|
290
|
+
expect(window.gaps).toEqual([]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('history window - gap markers are cleared when the whole window is replaced', async () => {
|
|
294
|
+
const window = new TestableHistoryWindow();
|
|
295
|
+
window.limit = 10;
|
|
296
|
+
window.fetchLimit = 3;
|
|
297
|
+
|
|
298
|
+
await window.jumpTo('1'); // [0,1,2]
|
|
299
|
+
await window.resyncToLatest(); // [0,1,2] | [7,8,9]
|
|
300
|
+
|
|
301
|
+
expect(window.gaps).toEqual([7]);
|
|
302
|
+
|
|
303
|
+
await window.resetToLatest(true); // [7,8,9] - a continuous window again
|
|
304
|
+
|
|
305
|
+
expect(window.items.map(item => item.id)).toEqual([7, 8, 9]);
|
|
306
|
+
expect(window.gaps).toEqual([]);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test('history window - gap marker is forgotten when trimmed out of the window', async () => {
|
|
310
|
+
const window = new TestableHistoryWindow();
|
|
311
|
+
window.limit = 10;
|
|
312
|
+
window.fetchLimit = 3;
|
|
313
|
+
|
|
314
|
+
await window.jumpTo('1'); // [0,1,2]
|
|
315
|
+
await window.resyncToLatest(); // [0,1,2] | [7,8,9]
|
|
316
|
+
|
|
317
|
+
expect(window.gaps).toEqual([7]);
|
|
318
|
+
|
|
319
|
+
// The window shrinks and the trimming pushes item 7 (and everything above
|
|
320
|
+
// it) out, so the marker has nothing left to point at.
|
|
321
|
+
window.limit = 2;
|
|
322
|
+
await window.resyncToLatest();
|
|
323
|
+
|
|
324
|
+
expect(window.items.map(item => item.id)).toEqual([8, 9]);
|
|
325
|
+
expect(window.gaps).toEqual([]);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('history window - resync to latest keeps loaded items when the page is not full', async () => {
|
|
329
|
+
const window = new TestableHistoryWindow();
|
|
330
|
+
window.limit = 10;
|
|
331
|
+
window.fetchLimit = 5; // The fetch returns 3 items only - all there is.
|
|
332
|
+
|
|
333
|
+
await window.jumpTo('1'); // [0,1,2,3]
|
|
334
|
+
|
|
335
|
+
// Nothing is missing in between: the page is everything the remote side has,
|
|
336
|
+
// so the loaded items are kept even though the page does not reach them.
|
|
337
|
+
await window.resyncToLatest();
|
|
338
|
+
|
|
339
|
+
expect(window.state).toEqual(WindowState.LATEST);
|
|
340
|
+
expect(window.items.map(item => item.id)).toEqual([0, 1, 2, 3, 7, 8, 9]);
|
|
341
|
+
});
|
|
342
|
+
|
|
212
343
|
test('history window - jump to message', async () => {
|
|
213
344
|
const window = new TestableHistoryWindow();
|
|
214
345
|
window.limit = 5;
|