polfan-server-js-client 0.2.108 → 0.2.110

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.
Files changed (31) hide show
  1. package/.idea/shelf/Uncommitted_changes_before_Checkout_at_26_07_2026_21_26_[Changes]/shelved.patch +174 -0
  2. package/.idea/shelf/Uncommitted_changes_before_Checkout_at_26_07_2026_21_26__Changes_.xml +4 -0
  3. package/.idea/workspace.xml +16 -16
  4. package/build/index.cjs.js +113 -521
  5. package/build/index.cjs.js.map +1 -1
  6. package/build/index.umd.js +1 -1
  7. package/build/index.umd.js.map +1 -1
  8. package/build/types/AbstractChatClient.d.ts +0 -10
  9. package/build/types/IndexedObjectCollection.d.ts +0 -8
  10. package/build/types/WebSocketChatClient.d.ts +1 -17
  11. package/build/types/state-tracker/FollowedTopicsManager.d.ts +1 -18
  12. package/build/types/state-tracker/RoomMessagesHistory.d.ts +0 -12
  13. package/build/types/state-tracker/TopicHistoryWindow.d.ts +2 -2
  14. package/package.json +1 -1
  15. package/src/AbstractChatClient.ts +0 -22
  16. package/src/IndexedObjectCollection.ts +0 -31
  17. package/src/WebSocketChatClient.ts +7 -63
  18. package/src/state-tracker/AsyncUtils.ts +0 -11
  19. package/src/state-tracker/EmoticonsManager.ts +3 -7
  20. package/src/state-tracker/FollowedTopicsManager.ts +14 -55
  21. package/src/state-tracker/MessagesManager.ts +2 -21
  22. package/src/state-tracker/PermissionsManager.ts +1 -5
  23. package/src/state-tracker/RelationshipsManager.ts +5 -5
  24. package/src/state-tracker/RoomMessagesHistory.ts +1 -41
  25. package/src/state-tracker/RoomsManager.ts +4 -21
  26. package/src/state-tracker/SpacesManager.ts +8 -35
  27. package/src/state-tracker/TopicHistoryWindow.ts +4 -4
  28. package/src/state-tracker/UsersManager.ts +1 -3
  29. package/tests/collections.test.ts +0 -92
  30. package/tests/pending-commands.test.ts +0 -171
  31. package/tests/state-reconnect.test.ts +0 -257
@@ -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
- const stateRoomIds = new Set(ev.state.rooms.map(room => room.id));
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 (! this.rooms.has(ev.id)) {
234
+ if (!this.rooms.has(ev.id)) {
239
235
  this.rooms.set([ev.id, new ObservableIndexedObjectCollection('id', ev.summaries)]);
240
- } else {
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
- const stateSpaceIds = new Set(ev.state.spaces.map(space => space.id));
296
-
297
- // Remove only spaces that were left/deleted on the server during the
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
- // Reconcile roles in place (kept, possibly bound object) and upsert the
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(force: boolean = false): Promise<void> {
115
- if (this.internalState.ongoing || (! force && this.internalState.current === WindowState.LATEST)) {
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(force: boolean = false): Promise<void> {
343
+ public async resetToLatest(): Promise<void> {
344
344
  if (this.internalState.traverseLock) {
345
345
  return;
346
346
  }
347
- return super.resetToLatest(force);
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
- // Keep the "seen users" cache across reconnects so bound user lists do
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
- })
@@ -1,257 +0,0 @@
1
- import {EventTarget} from "../src/EventTarget";
2
- import {RoomsManager} from "../src/state-tracker/RoomsManager";
3
- import {SpacesManager} from "../src/state-tracker/SpacesManager";
4
- import {WindowState} from "../src/state-tracker/TopicHistoryWindow";
5
-
6
- /**
7
- * These tests exercise the reconnect (second `Session`) reconciliation path of
8
- * the state tracker: collections must keep their identity, surviving rooms and
9
- * spaces must not be wiped and rebuilt, ephemeral message history must be
10
- * preserved, and only the entities actually removed on the server may be
11
- * dropped. See ChatStateTracker managers `handleSession`.
12
- */
13
-
14
- type Responder = (data: any) => any;
15
-
16
- class FakeClient extends EventTarget {
17
- public readonly sent: { type: string; data: any }[] = [];
18
- private readonly responders: Record<string, Responder> = {};
19
-
20
- public respondTo(type: string, responder: Responder): void {
21
- this.responders[type] = responder;
22
- }
23
-
24
- public async send(type: string, data: any): Promise<{ data: any; error: any }> {
25
- this.sent.push({ type, data });
26
- const responder = this.responders[type];
27
- return { data: responder ? responder(data) : undefined, error: null };
28
- }
29
-
30
- public countSent(type: string): number {
31
- return this.sent.filter(entry => entry.type === type).length;
32
- }
33
- }
34
-
35
- const flush = async (): Promise<void> => {
36
- // Drain microtasks scheduled by the fire-and-forget resync chain.
37
- await new Promise(resolve => setTimeout(resolve, 0));
38
- await new Promise(resolve => setTimeout(resolve, 0));
39
- };
40
-
41
- const createRoom = (id: string, overrides: any = {}): any => ({
42
- id,
43
- spaceId: null,
44
- name: id,
45
- description: '',
46
- type: 'Text',
47
- defaultTopic: { id: `topic-${id}`, messageCount: 0, lastMessage: null },
48
- recipients: null,
49
- flags: 0,
50
- stream: null,
51
- history: { mode: 'Full' },
52
- ...overrides,
53
- });
54
-
55
- const createSpace = (id: string, roles: any[] = []): any => ({
56
- id,
57
- name: id,
58
- roles,
59
- defaultRooms: [],
60
- systemRoom: null,
61
- });
62
-
63
- const emitSession = (client: FakeClient, rooms: any[], spaces: any[] = []): void => {
64
- client.emit('Session', {
65
- serverVersion: '1.0.0',
66
- protoVersion: '1.0.0',
67
- user: { id: 'me' },
68
- state: { rooms, spaces },
69
- } as any);
70
- };
71
-
72
- const createTracker = () => {
73
- const client = new FakeClient();
74
- const tracker: any = { client, getMe: async () => ({ id: 'me' }) };
75
- tracker.rooms = new RoomsManager(tracker);
76
- tracker.spaces = new SpacesManager(tracker);
77
- return { client, tracker };
78
- };
79
-
80
- describe('reconnect - RoomsManager', () => {
81
- test('preserves the rooms list identity and reconciles membership', async () => {
82
- const { client, tracker } = createTracker();
83
-
84
- emitSession(client, [createRoom('A'), createRoom('B')]);
85
- const list = await tracker.rooms.get();
86
-
87
- expect(list.items.map((r: any) => r.id).sort()).toEqual(['A', 'B']);
88
-
89
- // Reconnect: B is gone, C joined, A survived (renamed).
90
- emitSession(client, [createRoom('A', { name: 'A-renamed' }), createRoom('C')]);
91
- const listAfter = await tracker.rooms.get();
92
-
93
- expect(listAfter).toBe(list); // same object -> bindings intact
94
- expect(listAfter.items.map((r: any) => r.id).sort()).toEqual(['A', 'C']);
95
- expect(listAfter.get('A').name).toBe('A-renamed');
96
- expect(listAfter.get('B')).toBeUndefined();
97
- });
98
-
99
- test('keeps a member collection object across reconnect and refetches it', async () => {
100
- const { client, tracker } = createTracker();
101
- client.respondTo('GetRoomMembers', (data: any) => ({
102
- id: data.id,
103
- members: [
104
- { user: { id: 'me' }, spaceMember: null, roles: null, customColor: null, customNick: null, extras: '' },
105
- { user: { id: 'u1' }, spaceMember: null, roles: null, customColor: null, customNick: null, extras: '' },
106
- ],
107
- }));
108
-
109
- emitSession(client, [createRoom('A')]);
110
- const membersBefore = await tracker.rooms.getMembers('A');
111
- expect(membersBefore.items.map((m: any) => m.user.id).sort()).toEqual(['me', 'u1']);
112
- expect(client.countSent('GetRoomMembers')).toBe(1);
113
-
114
- // After reconnect u1 has left; the refetch must reconcile in place.
115
- client.respondTo('GetRoomMembers', (data: any) => ({
116
- id: data.id,
117
- members: [
118
- { user: { id: 'me' }, spaceMember: null, roles: null, customColor: null, customNick: null, extras: '' },
119
- ],
120
- }));
121
-
122
- emitSession(client, [createRoom('A')]);
123
- const membersAfter = await tracker.rooms.getMembers('A');
124
-
125
- expect(membersAfter).toBe(membersBefore); // same object -> no blank
126
- expect(client.countSent('GetRoomMembers')).toBe(2); // guard was dropped -> refetched
127
- expect(membersAfter.items.map((m: any) => m.user.id)).toEqual(['me']);
128
- });
129
- });
130
-
131
- describe('reconnect - MessagesManager', () => {
132
- const emitNewMessage = (client: FakeClient, roomId: string, topicId: string, id: string): void => {
133
- client.emit('NewMessage', {
134
- message: {
135
- id,
136
- type: 'Text',
137
- content: '',
138
- location: { roomId, topicId },
139
- author: { user: { id: 'other' } },
140
- },
141
- } as any);
142
- };
143
-
144
- test('preserves ephemeral live history across reconnect (no refetch, no loss)', async () => {
145
- const { client, tracker } = createTracker();
146
-
147
- emitSession(client, [createRoom('E', { history: { mode: 'Ephemeral' } })]);
148
-
149
- const history = await tracker.rooms.messages.getRoomHistory('E');
150
- const window = await history.getMessagesWindow('topic-E');
151
- expect(window.state).toBe(WindowState.LIVE);
152
-
153
- emitNewMessage(client, 'E', 'topic-E', 'm1');
154
- emitNewMessage(client, 'E', 'topic-E', 'm2');
155
- expect(window.items.map((m: any) => m.id)).toEqual(['m1', 'm2']);
156
-
157
- client.sent.length = 0;
158
-
159
- // Reconnect - the whole point: ephemeral context must survive.
160
- emitSession(client, [createRoom('E', { history: { mode: 'Ephemeral' } })]);
161
- await flush();
162
-
163
- const historyAfter = await tracker.rooms.messages.getRoomHistory('E');
164
- const windowAfter = await historyAfter.getMessagesWindow('topic-E');
165
-
166
- expect(historyAfter).toBe(history); // history object preserved
167
- expect(windowAfter).toBe(window); // window object preserved
168
- expect(windowAfter.items.map((m: any) => m.id)).toEqual(['m1', 'm2']);
169
- expect(client.countSent('GetMessages')).toBe(0); // never refetched
170
- });
171
-
172
- test('refreshes a LATEST window but leaves an untouched LIVE window alone', async () => {
173
- const { client, tracker } = createTracker();
174
- client.respondTo('GetMessages', () => ({ messages: [{ id: 'x1' }, { id: 'x2' }] }));
175
-
176
- // R was opened (pulled to LATEST); L was only ever created (LIVE).
177
- emitSession(client, [createRoom('R'), createRoom('L')]);
178
-
179
- const rHistory = await tracker.rooms.messages.getRoomHistory('R');
180
- const rWindow = await rHistory.getMessagesWindow('topic-R');
181
- await rWindow.resetToLatest();
182
- expect(rWindow.state).toBe(WindowState.LATEST);
183
-
184
- const lHistory = await tracker.rooms.messages.getRoomHistory('L');
185
- const lWindow = await lHistory.getMessagesWindow('topic-L');
186
- expect(lWindow.state).toBe(WindowState.LIVE);
187
-
188
- client.sent.length = 0;
189
-
190
- emitSession(client, [createRoom('R'), createRoom('L')]);
191
- await flush();
192
-
193
- // LATEST window was refreshed with exactly one request; identity kept.
194
- expect((await tracker.rooms.messages.getRoomHistory('R'))).toBe(rHistory);
195
- expect(client.sent.filter(e => e.type === 'GetMessages'
196
- && e.data.location.roomId === 'R').length).toBe(1);
197
- // LIVE window (never pulled by the app) was left untouched.
198
- expect(client.sent.filter(e => e.type === 'GetMessages'
199
- && e.data.location.roomId === 'L').length).toBe(0);
200
- });
201
- });
202
-
203
- describe('reconnect - SpacesManager', () => {
204
- test('reconciles roles in place and drops only removed spaces', async () => {
205
- const { client, tracker } = createTracker();
206
-
207
- const s1Roles = [
208
- { id: 'r1', name: 'everyone', priority: 0, color: '#fff' },
209
- { id: 'r2', name: 'mod', priority: 1, color: '#fff' },
210
- ];
211
- emitSession(client, [], [createSpace('S1', s1Roles), createSpace('S2')]);
212
-
213
- const spaces = await tracker.spaces.get();
214
- const rolesS1 = await tracker.spaces.getRoles('S1');
215
- expect(spaces.items.map((s: any) => s.id).sort()).toEqual(['S1', 'S2']);
216
- expect(rolesS1.items.map((r: any) => r.id).sort()).toEqual(['r1', 'r2']);
217
-
218
- // Reconnect: S2 removed, S3 joined; S1 roles changed (r2 removed, r3 added).
219
- const s1RolesNew = [
220
- { id: 'r1', name: 'everyone', priority: 0, color: '#fff' },
221
- { id: 'r3', name: 'vip', priority: 2, color: '#fff' },
222
- ];
223
- emitSession(client, [], [createSpace('S1', s1RolesNew), createSpace('S3')]);
224
-
225
- const spacesAfter = await tracker.spaces.get();
226
- const rolesS1After = await tracker.spaces.getRoles('S1');
227
-
228
- expect(spacesAfter).toBe(spaces);
229
- expect(spacesAfter.items.map((s: any) => s.id).sort()).toEqual(['S1', 'S3']);
230
- expect(rolesS1After).toBe(rolesS1); // roles collection identity preserved
231
- expect(rolesS1After.items.map((r: any) => r.id).sort()).toEqual(['r1', 'r3']);
232
- });
233
-
234
- test('keeps a space member collection across reconnect and refetches it', async () => {
235
- const { client, tracker } = createTracker();
236
- client.respondTo('GetSpaceMembers', (data: any) => ({
237
- id: data.id,
238
- members: [{ user: { id: 'me' } }, { user: { id: 'u1' } }],
239
- }));
240
-
241
- emitSession(client, [], [createSpace('S1')]);
242
- const membersBefore = await tracker.spaces.getMembers('S1');
243
- expect(client.countSent('GetSpaceMembers')).toBe(1);
244
-
245
- client.respondTo('GetSpaceMembers', (data: any) => ({
246
- id: data.id,
247
- members: [{ user: { id: 'me' } }],
248
- }));
249
-
250
- emitSession(client, [], [createSpace('S1')]);
251
- const membersAfter = await tracker.spaces.getMembers('S1');
252
-
253
- expect(membersAfter).toBe(membersBefore);
254
- expect(client.countSent('GetSpaceMembers')).toBe(2);
255
- expect(membersAfter.items.map((m: any) => m.user.id)).toEqual(['me']);
256
- });
257
- });