polfan-server-js-client 0.2.107 → 0.2.109
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/shelf/Uncommitted_changes_before_Checkout_at_26_07_2026_21_26_[Changes]/shelved.patch +174 -0
- package/.idea/shelf/Uncommitted_changes_before_Checkout_at_26_07_2026_21_26__Changes_.xml +4 -0
- package/.idea/workspace.xml +21 -13
- package/build/index.cjs.js +110 -524
- 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/AbstractChatClient.d.ts +0 -10
- package/build/types/IndexedObjectCollection.d.ts +0 -8
- package/build/types/WebSocketChatClient.d.ts +1 -17
- package/build/types/index.d.ts +2 -1
- package/build/types/state-tracker/FollowedTopicsManager.d.ts +6 -22
- package/build/types/state-tracker/RoomMessagesHistory.d.ts +0 -12
- package/build/types/state-tracker/TopicHistoryWindow.d.ts +2 -2
- package/package.json +1 -1
- package/src/AbstractChatClient.ts +0 -22
- package/src/IndexedObjectCollection.ts +0 -31
- package/src/WebSocketChatClient.ts +7 -63
- package/src/index.ts +2 -0
- package/src/state-tracker/AsyncUtils.ts +0 -11
- package/src/state-tracker/EmoticonsManager.ts +3 -7
- package/src/state-tracker/FollowedTopicsManager.ts +17 -59
- package/src/state-tracker/MessagesManager.ts +2 -21
- package/src/state-tracker/PermissionsManager.ts +1 -5
- package/src/state-tracker/RelationshipsManager.ts +5 -5
- package/src/state-tracker/RoomMessagesHistory.ts +1 -41
- package/src/state-tracker/RoomsManager.ts +4 -21
- package/src/state-tracker/SpacesManager.ts +8 -35
- package/src/state-tracker/TopicHistoryWindow.ts +4 -4
- package/src/state-tracker/UsersManager.ts +1 -3
- package/tests/collections.test.ts +0 -92
- package/tests/pending-commands.test.ts +0 -171
- package/tests/state-reconnect.test.ts +0 -257
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {ChatStateTracker} from "./ChatStateTracker";
|
|
2
2
|
import {NewTopic, Room, RoomUpdated, Topic, TopicDeleted} from "../types/src";
|
|
3
3
|
import {IndexedCollection,} from "../IndexedObjectCollection";
|
|
4
|
-
import {TopicHistoryWindow
|
|
4
|
+
import {TopicHistoryWindow} from "./TopicHistoryWindow";
|
|
5
5
|
|
|
6
6
|
export class RoomMessagesHistory {
|
|
7
7
|
private historyWindows = new IndexedCollection<string, TopicHistoryWindow>();
|
|
@@ -39,46 +39,6 @@ export class RoomMessagesHistory {
|
|
|
39
39
|
return this.historyWindows.get(topicId);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
* Re-synchronise this room's history after a reconnect without discarding
|
|
44
|
-
* the existing window objects (which would blank the UI and, for ephemeral
|
|
45
|
-
* rooms, permanently drop live-only history).
|
|
46
|
-
*
|
|
47
|
-
* The window bindings are preserved; only windows that the application had
|
|
48
|
-
* actually pulled to the latest page (state === LATEST) are refreshed, with
|
|
49
|
-
* a single resetToLatest instead of a chain of catch-up requests. Windows
|
|
50
|
-
* that were never pulled (LIVE) or belong to an ephemeral room are left
|
|
51
|
-
* untouched so their in-memory context survives the reconnect.
|
|
52
|
-
*/
|
|
53
|
-
public async resync(room: Room): Promise<void> {
|
|
54
|
-
this.room = room;
|
|
55
|
-
this.updateTraverseLock(room);
|
|
56
|
-
|
|
57
|
-
if (this.room.defaultTopic) {
|
|
58
|
-
this.createHistoryWindowForTopic(this.room.defaultTopic);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
for (const [, window] of Array.from(this.historyWindows.items)) {
|
|
62
|
-
try {
|
|
63
|
-
await window.setTraverseLock(this.traverseLock);
|
|
64
|
-
|
|
65
|
-
// Ephemeral history lives only in memory (the server does not
|
|
66
|
-
// persist it), so never refetch/replace it on reconnect.
|
|
67
|
-
if (this.traverseLock) {
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (window.state === WindowState.LATEST) {
|
|
72
|
-
await window.resetToLatest(true);
|
|
73
|
-
}
|
|
74
|
-
} catch (_e) {
|
|
75
|
-
// Best effort: the connection can drop again mid-resync. The
|
|
76
|
-
// window keeps its current content and stays usable, so the
|
|
77
|
-
// application can refresh it on demand.
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
42
|
private async handleRoomUpdated(ev: RoomUpdated): Promise<void> {
|
|
83
43
|
if (this.room.id === ev.room.id) {
|
|
84
44
|
this.room = ev.room;
|
|
@@ -303,33 +303,16 @@ export class RoomsManager {
|
|
|
303
303
|
ev.members,
|
|
304
304
|
)
|
|
305
305
|
]);
|
|
306
|
-
} else {
|
|
307
|
-
// Reconcile into the existing (bound) collection so a reconnect
|
|
308
|
-
// refetch updates it in place instead of leaving stale members.
|
|
309
|
-
this.members.get(ev.id).reconcile(...ev.members);
|
|
310
306
|
}
|
|
311
307
|
}
|
|
312
308
|
|
|
313
309
|
private handleSession(ev: Session): void {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
// Remove only rooms that were left/deleted on the server during the
|
|
317
|
-
// downtime, reusing the cascade cleanup (members, topics, followed
|
|
318
|
-
// topics). Surviving rooms keep their identity and bindings.
|
|
319
|
-
const removedRoomIds = this.list.items
|
|
320
|
-
.filter(room => ! stateRoomIds.has(room.id))
|
|
321
|
-
.map(room => room.id);
|
|
322
|
-
if (removedRoomIds.length) {
|
|
323
|
-
this.deleteRoom(...removedRoomIds);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Invalidate lazy caches so the next access refetches fresh data, but
|
|
327
|
-
// keep the collection objects: handleRoomMembers reconciles into them,
|
|
328
|
-
// so bound views refresh in place without blanking.
|
|
329
|
-
this.membersPromises.forgetAll();
|
|
310
|
+
this.list.deleteAll();
|
|
311
|
+
this.topics.deleteAll();
|
|
330
312
|
this.topicsPromises.forgetAll();
|
|
313
|
+
this.members.deleteAll();
|
|
314
|
+
this.membersPromises.forgetAll();
|
|
331
315
|
|
|
332
|
-
// Upsert surviving/new rooms from the authoritative snapshot.
|
|
333
316
|
this.addJoinedRooms(...ev.state.rooms);
|
|
334
317
|
|
|
335
318
|
this.deferredSession.resolve();
|
|
@@ -227,20 +227,14 @@ export class SpacesManager {
|
|
|
227
227
|
ev.id,
|
|
228
228
|
new ObservableIndexedObjectCollection(member => member?.user.id, ev.members)
|
|
229
229
|
]);
|
|
230
|
-
} else {
|
|
231
|
-
// Reconcile into the existing (bound) collection so a reconnect
|
|
232
|
-
// refetch updates it in place instead of leaving stale members.
|
|
233
|
-
this.members.get(ev.id).reconcile(...ev.members);
|
|
234
230
|
}
|
|
235
231
|
}
|
|
236
232
|
|
|
237
233
|
private handleSpaceRooms(ev: SpaceRooms): void {
|
|
238
|
-
if (!
|
|
234
|
+
if (!this.rooms.has(ev.id)) {
|
|
239
235
|
this.rooms.set([ev.id, new ObservableIndexedObjectCollection('id', ev.summaries)]);
|
|
240
|
-
|
|
241
|
-
this.rooms.get(ev.id).reconcile(...ev.summaries);
|
|
236
|
+
ev.summaries.forEach(summary => this.roomIdToSpaceId.set([summary.id, ev.id]));
|
|
242
237
|
}
|
|
243
|
-
ev.summaries.forEach(summary => this.roomIdToSpaceId.set([summary.id, ev.id]));
|
|
244
238
|
}
|
|
245
239
|
|
|
246
240
|
private async handleRoomSummaryUpdated(ev: RoomSummaryUpdated): Promise<void> {
|
|
@@ -292,36 +286,15 @@ export class SpacesManager {
|
|
|
292
286
|
}
|
|
293
287
|
|
|
294
288
|
private handleSession(ev: Session): void {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
// downtime, reusing the cascade cleanup (roles, rooms, members, index).
|
|
299
|
-
const removedSpaceIds = this.list.items
|
|
300
|
-
.filter(space => ! stateSpaceIds.has(space.id))
|
|
301
|
-
.map(space => space.id);
|
|
302
|
-
for (const spaceId of removedSpaceIds) {
|
|
303
|
-
this.handleSpaceDeleted({ id: spaceId } as SpaceDeleted);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// Invalidate lazy caches (rooms/members) but keep their objects so the
|
|
307
|
-
// next access refetches and reconciles them in place.
|
|
289
|
+
this.list.deleteAll();
|
|
290
|
+
this.roles.deleteAll();
|
|
291
|
+
this.rooms.deleteAll();
|
|
308
292
|
this.roomsPromises.forgetAll();
|
|
293
|
+
this.members.deleteAll();
|
|
309
294
|
this.membersPromises.forgetAll();
|
|
295
|
+
this.roomIdToSpaceId.deleteAll();
|
|
310
296
|
|
|
311
|
-
|
|
312
|
-
// spaces from the authoritative snapshot.
|
|
313
|
-
for (const space of ev.state.spaces) {
|
|
314
|
-
if (this.roles.has(space.id)) {
|
|
315
|
-
this.roles.get(space.id).reconcile(...space.roles);
|
|
316
|
-
} else {
|
|
317
|
-
this.roles.set([
|
|
318
|
-
space.id,
|
|
319
|
-
new ObservableIndexedObjectCollection<Role>('id', space.roles),
|
|
320
|
-
]);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
this.list.set(...ev.state.spaces);
|
|
297
|
+
this.addJoinedSpaces(...ev.state.spaces);
|
|
325
298
|
|
|
326
299
|
this.deferredSession.resolve();
|
|
327
300
|
}
|
|
@@ -111,8 +111,8 @@ export abstract class TraversableRemoteCollection<
|
|
|
111
111
|
|
|
112
112
|
public abstract createMirror(): TraversableRemoteCollection<ItemT, EventMapT>;
|
|
113
113
|
|
|
114
|
-
public async resetToLatest(
|
|
115
|
-
if (this.internalState.ongoing ||
|
|
114
|
+
public async resetToLatest(): Promise<void> {
|
|
115
|
+
if (this.internalState.ongoing || this.internalState.current === WindowState.LATEST) {
|
|
116
116
|
return;
|
|
117
117
|
}
|
|
118
118
|
|
|
@@ -340,11 +340,11 @@ export class TopicHistoryWindow extends TraversableRemoteCollection<
|
|
|
340
340
|
}
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
-
public async resetToLatest(
|
|
343
|
+
public async resetToLatest(): Promise<void> {
|
|
344
344
|
if (this.internalState.traverseLock) {
|
|
345
345
|
return;
|
|
346
346
|
}
|
|
347
|
-
return super.resetToLatest(
|
|
347
|
+
return super.resetToLatest();
|
|
348
348
|
}
|
|
349
349
|
|
|
350
350
|
public async fetchNext(): Promise<void> {
|
|
@@ -33,9 +33,7 @@ export class UsersManager {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
private handleSession(session: Session): void {
|
|
36
|
-
|
|
37
|
-
// not blank out; just ensure our own user is present/updated. Stale
|
|
38
|
-
// entries are refreshed as member/message collections refetch.
|
|
36
|
+
this.users.deleteAll();
|
|
39
37
|
this.handleUsers([session.user]);
|
|
40
38
|
}
|
|
41
39
|
|
|
@@ -759,96 +759,4 @@ describe('ObservableIndexedObjectCollection', () => {
|
|
|
759
759
|
expect(mirror.get('2')).toBeDefined();
|
|
760
760
|
expect(mirror.length).toBe(2);
|
|
761
761
|
});
|
|
762
|
-
|
|
763
|
-
test('reconcile - upserts new/changed items and deletes absent ones', () => {
|
|
764
|
-
const collection = new ObservableIndexedObjectCollection<TestItem>('id', [
|
|
765
|
-
createItem('1', 'first'),
|
|
766
|
-
createItem('2', 'second'),
|
|
767
|
-
createItem('3', 'third'),
|
|
768
|
-
]);
|
|
769
|
-
|
|
770
|
-
// Keep 1 (changed), keep 3 (unchanged), drop 2, add 4.
|
|
771
|
-
collection.reconcile(
|
|
772
|
-
createItem('1', 'first-renamed'),
|
|
773
|
-
createItem('3', 'third'),
|
|
774
|
-
createItem('4', 'fourth'),
|
|
775
|
-
);
|
|
776
|
-
|
|
777
|
-
expect(collection.length).toBe(3);
|
|
778
|
-
expect(collection.get('1')?.name).toBe('first-renamed');
|
|
779
|
-
expect(collection.get('2')).toBeUndefined();
|
|
780
|
-
expect(collection.get('3')?.name).toBe('third');
|
|
781
|
-
expect(collection.get('4')?.name).toBe('fourth');
|
|
782
|
-
});
|
|
783
|
-
|
|
784
|
-
test('reconcile - emits a single change event with set and deleted ids', () => {
|
|
785
|
-
const collection = new ObservableIndexedObjectCollection<TestItem>('id', [
|
|
786
|
-
createItem('1', 'first'),
|
|
787
|
-
createItem('2', 'second'),
|
|
788
|
-
]);
|
|
789
|
-
const handler = jest.fn();
|
|
790
|
-
collection.on('change', handler);
|
|
791
|
-
|
|
792
|
-
collection.reconcile(createItem('1', 'first'), createItem('3', 'third'));
|
|
793
|
-
|
|
794
|
-
expect(handler).toHaveBeenCalledTimes(1);
|
|
795
|
-
expect(handler).toHaveBeenCalledWith({ setItems: ['1', '3'], deletedItems: ['2'] });
|
|
796
|
-
});
|
|
797
|
-
|
|
798
|
-
test('reconcile - never blanks: existing item stays present the whole time', () => {
|
|
799
|
-
const collection = new ObservableIndexedObjectCollection<TestItem>('id', [
|
|
800
|
-
createItem('1', 'first'),
|
|
801
|
-
createItem('2', 'second'),
|
|
802
|
-
]);
|
|
803
|
-
|
|
804
|
-
// A change listener that reads the collection while it is being
|
|
805
|
-
// reconciled must never observe an empty/partial intermediate state.
|
|
806
|
-
const seenLengths: number[] = [];
|
|
807
|
-
collection.on('change', () => seenLengths.push(collection.length));
|
|
808
|
-
|
|
809
|
-
collection.reconcile(createItem('1', 'first'), createItem('3', 'third'));
|
|
810
|
-
|
|
811
|
-
// Only the final state (2 items) is ever visible - no 0-length frame.
|
|
812
|
-
expect(seenLengths).toEqual([2]);
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
test('reconcile - does not emit when nothing changes structurally on empty input', () => {
|
|
816
|
-
const collection = new ObservableIndexedObjectCollection<TestItem>('id');
|
|
817
|
-
const handler = jest.fn();
|
|
818
|
-
collection.on('change', handler);
|
|
819
|
-
|
|
820
|
-
collection.reconcile();
|
|
821
|
-
|
|
822
|
-
expect(handler).not.toHaveBeenCalled();
|
|
823
|
-
expect(collection.length).toBe(0);
|
|
824
|
-
});
|
|
825
|
-
|
|
826
|
-
test('reconcile - reconciling to empty clears the collection', () => {
|
|
827
|
-
const collection = new ObservableIndexedObjectCollection<TestItem>('id', [
|
|
828
|
-
createItem('1', 'first'),
|
|
829
|
-
]);
|
|
830
|
-
const handler = jest.fn();
|
|
831
|
-
collection.on('change', handler);
|
|
832
|
-
|
|
833
|
-
collection.reconcile();
|
|
834
|
-
|
|
835
|
-
expect(collection.length).toBe(0);
|
|
836
|
-
expect(handler).toHaveBeenCalledWith({ setItems: [], deletedItems: ['1'] });
|
|
837
|
-
});
|
|
838
|
-
|
|
839
|
-
test('reconcile - preserves collection identity (mirror sees updates)', () => {
|
|
840
|
-
const collection = new ObservableIndexedObjectCollection<TestItem>('id', [
|
|
841
|
-
createItem('1', 'first'),
|
|
842
|
-
createItem('2', 'second'),
|
|
843
|
-
]);
|
|
844
|
-
const mirror = collection.createMirror();
|
|
845
|
-
|
|
846
|
-
collection.reconcile(createItem('2', 'second'), createItem('3', 'third'));
|
|
847
|
-
|
|
848
|
-
// The mirror shares the same underlying items, so it reflects the
|
|
849
|
-
// reconcile without being re-created.
|
|
850
|
-
expect(mirror.get('1')).toBeUndefined();
|
|
851
|
-
expect(mirror.get('3')?.name).toBe('third');
|
|
852
|
-
expect(mirror.length).toBe(2);
|
|
853
|
-
});
|
|
854
762
|
});
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import {PromiseRegistry} from "../src/state-tracker/AsyncUtils";
|
|
2
|
-
import {AbstractChatClient} from "../src/AbstractChatClient";
|
|
3
|
-
import {TraversableRemoteCollection, WindowState} from "../src/state-tracker/TopicHistoryWindow";
|
|
4
|
-
import {Envelope} from "../src/types/src";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* A command issued while the client is offline (or one that is still in flight
|
|
8
|
-
* when the socket drops) must always settle. Leaving it pending strands every
|
|
9
|
-
* caller awaiting it - including the state tracker's cached lookups and the
|
|
10
|
-
* history window's `ongoing` guard, which would keep a view stuck on a loader
|
|
11
|
-
* (or permanently empty) even after a successful reconnect.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
class TestableChatClient extends AbstractChatClient {
|
|
15
|
-
public async send(type: any, data: any): Promise<any> {
|
|
16
|
-
const envelope = this.createEnvelope(type, data);
|
|
17
|
-
return this.createPromiseFromCommandEnvelope(envelope as any);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
public respond(ref: string, type: string, data: any): void {
|
|
21
|
-
this.handleIncomingEnvelope({ ref, type, data } as Envelope);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
public dropConnection(error: Error): void {
|
|
25
|
-
this.failAwaitingResponses(error);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
public get pendingCount(): number {
|
|
29
|
-
return this.awaitingResponse.size;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
describe('pending commands settle on connection loss', () => {
|
|
34
|
-
test('in-flight commands reject instead of hanging forever', async () => {
|
|
35
|
-
const client = new TestableChatClient();
|
|
36
|
-
|
|
37
|
-
const first = client.send('GetMessages', {});
|
|
38
|
-
const second = client.send('GetRoomMembers', {});
|
|
39
|
-
|
|
40
|
-
expect(client.pendingCount).toBe(2);
|
|
41
|
-
|
|
42
|
-
client.dropConnection(new Error('Connection closed'));
|
|
43
|
-
|
|
44
|
-
await expect(first).rejects.toThrow('Connection closed');
|
|
45
|
-
await expect(second).rejects.toThrow('Connection closed');
|
|
46
|
-
expect(client.pendingCount).toBe(0);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test('commands answered before the drop are unaffected', async () => {
|
|
50
|
-
const client = new TestableChatClient();
|
|
51
|
-
|
|
52
|
-
const answered = client.send('GetMessages', {});
|
|
53
|
-
client.respond('1', 'Messages', { messages: [] });
|
|
54
|
-
|
|
55
|
-
const inFlight = client.send('GetRoomMembers', {});
|
|
56
|
-
client.dropConnection(new Error('Connection closed'));
|
|
57
|
-
|
|
58
|
-
await expect(answered).resolves.toEqual({ data: { messages: [] }, error: null });
|
|
59
|
-
await expect(inFlight).rejects.toThrow('Connection closed');
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe('PromiseRegistry does not cache failures', () => {
|
|
64
|
-
test('a rejected lookup is forgotten so the next access retries', async () => {
|
|
65
|
-
const registry = new PromiseRegistry();
|
|
66
|
-
let attempts = 0;
|
|
67
|
-
|
|
68
|
-
const run = async () => {
|
|
69
|
-
registry.registerByFunction(async () => {
|
|
70
|
-
attempts++;
|
|
71
|
-
if (attempts === 1) {
|
|
72
|
-
throw new Error('offline');
|
|
73
|
-
}
|
|
74
|
-
return 'ok';
|
|
75
|
-
}, 'key');
|
|
76
|
-
|
|
77
|
-
return registry.get('key');
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
// First attempt fails while "offline".
|
|
81
|
-
await expect(run()).rejects.toThrow('offline');
|
|
82
|
-
|
|
83
|
-
// The failed entry must not be cached, otherwise every later access
|
|
84
|
-
// would replay the same rejection forever.
|
|
85
|
-
expect(registry.notExist('key')).toBe(true);
|
|
86
|
-
|
|
87
|
-
await expect(run()).resolves.toBe('ok');
|
|
88
|
-
expect(attempts).toBe(2);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test('a successful lookup stays cached', async () => {
|
|
92
|
-
const registry = new PromiseRegistry();
|
|
93
|
-
let attempts = 0;
|
|
94
|
-
|
|
95
|
-
registry.registerByFunction(async () => {
|
|
96
|
-
attempts++;
|
|
97
|
-
return 'ok';
|
|
98
|
-
}, 'key');
|
|
99
|
-
|
|
100
|
-
await registry.get('key');
|
|
101
|
-
|
|
102
|
-
expect(registry.has('key')).toBe(true);
|
|
103
|
-
expect(attempts).toBe(1);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test('a newer entry for the same key is not dropped by an older failure', async () => {
|
|
107
|
-
const registry = new PromiseRegistry();
|
|
108
|
-
|
|
109
|
-
const failing = Promise.reject(new Error('offline'));
|
|
110
|
-
registry.register(failing, 'key');
|
|
111
|
-
await expect(registry.get('key')).rejects.toThrow('offline');
|
|
112
|
-
|
|
113
|
-
const succeeding = Promise.resolve('ok');
|
|
114
|
-
registry.register(succeeding, 'key');
|
|
115
|
-
|
|
116
|
-
// Let the older rejection's cleanup handler run.
|
|
117
|
-
await new Promise(resolve => setTimeout(resolve, 0));
|
|
118
|
-
|
|
119
|
-
expect(registry.has('key')).toBe(true);
|
|
120
|
-
await expect(registry.get('key')).resolves.toBe('ok');
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
describe('history window survives a failed fetch', () => {
|
|
125
|
-
interface SimpleMessage { id: number }
|
|
126
|
-
|
|
127
|
-
class FlakyWindow extends TraversableRemoteCollection<SimpleMessage> {
|
|
128
|
-
public shouldFail = false;
|
|
129
|
-
|
|
130
|
-
public constructor() {
|
|
131
|
-
super('id');
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
public createMirror(): TraversableRemoteCollection<SimpleMessage> {
|
|
135
|
-
throw new Error('Method not implemented.');
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
protected async fetchLatestItems(): Promise<SimpleMessage[]> {
|
|
139
|
-
if (this.shouldFail) {
|
|
140
|
-
throw new Error('Connection closed before the command was answered');
|
|
141
|
-
}
|
|
142
|
-
return [{ id: 1 }, { id: 2 }];
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
protected async fetchItemsBefore(): Promise<SimpleMessage[] | null> { return []; }
|
|
146
|
-
protected async fetchItemsAfter(): Promise<SimpleMessage[] | null> { return []; }
|
|
147
|
-
protected async fetchItemsAround(): Promise<SimpleMessage[] | null> { return []; }
|
|
148
|
-
protected async isLatestItemLoaded(): Promise<boolean> { return true; }
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
test('a rejected fetch leaves the window reusable, not permanently blocked', async () => {
|
|
152
|
-
const window = new FlakyWindow();
|
|
153
|
-
|
|
154
|
-
// Simulates opening a room while the client is offline.
|
|
155
|
-
window.shouldFail = true;
|
|
156
|
-
await expect(window.resetToLatest()).rejects.toThrow('Connection closed');
|
|
157
|
-
|
|
158
|
-
// The failure must not leave the internal `ongoing` guard set, which
|
|
159
|
-
// would make every later fetch a silent no-op and strand the view on an
|
|
160
|
-
// empty window after the reconnect.
|
|
161
|
-
expect(window.state).toBe(WindowState.LIVE);
|
|
162
|
-
expect(window.length).toBe(0);
|
|
163
|
-
|
|
164
|
-
// Retry after reconnecting must actually fetch.
|
|
165
|
-
window.shouldFail = false;
|
|
166
|
-
await window.resetToLatest();
|
|
167
|
-
|
|
168
|
-
expect(window.state).toBe(WindowState.LATEST);
|
|
169
|
-
expect(window.items.map(item => item.id)).toEqual([1, 2]);
|
|
170
|
-
});
|
|
171
|
-
})
|