@juzi/wechaty-puppet-service 1.0.129 → 1.0.131

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 (98) hide show
  1. package/dist/cjs/src/auth/call-cred.d.ts +2 -2
  2. package/dist/cjs/src/auth/call-cred.d.ts.map +1 -1
  3. package/dist/cjs/src/auth/call-cred.js.map +1 -1
  4. package/dist/cjs/src/client/puppet-service.d.ts +27 -2
  5. package/dist/cjs/src/client/puppet-service.d.ts.map +1 -1
  6. package/dist/cjs/src/client/puppet-service.js +431 -124
  7. package/dist/cjs/src/client/puppet-service.js.map +1 -1
  8. package/dist/cjs/src/client/puppet-service.spec.js +107 -0
  9. package/dist/cjs/src/client/puppet-service.spec.js.map +1 -1
  10. package/dist/cjs/src/client/room-cache-coordinator.d.ts +14 -0
  11. package/dist/cjs/src/client/room-cache-coordinator.d.ts.map +1 -0
  12. package/dist/cjs/src/client/room-cache-coordinator.js +53 -0
  13. package/dist/cjs/src/client/room-cache-coordinator.js.map +1 -0
  14. package/dist/cjs/src/client/room-member-cache.spec.d.ts +2 -0
  15. package/dist/cjs/src/client/room-member-cache.spec.d.ts.map +1 -0
  16. package/dist/cjs/src/client/room-member-cache.spec.js +417 -0
  17. package/dist/cjs/src/client/room-member-cache.spec.js.map +1 -0
  18. package/dist/cjs/src/client/room-member-load.spec.d.ts +2 -0
  19. package/dist/cjs/src/client/room-member-load.spec.d.ts.map +1 -0
  20. package/dist/cjs/src/client/room-member-load.spec.js +324 -0
  21. package/dist/cjs/src/client/room-member-load.spec.js.map +1 -0
  22. package/dist/cjs/src/package-json.js +4 -4
  23. package/dist/cjs/src/server/event-stream-manager.d.ts.map +1 -1
  24. package/dist/cjs/src/server/event-stream-manager.js +30 -0
  25. package/dist/cjs/src/server/event-stream-manager.js.map +1 -1
  26. package/dist/cjs/src/server/event-stream-manager.spec.d.ts +3 -0
  27. package/dist/cjs/src/server/event-stream-manager.spec.d.ts.map +1 -0
  28. package/dist/cjs/src/server/event-stream-manager.spec.js +22 -0
  29. package/dist/cjs/src/server/event-stream-manager.spec.js.map +1 -0
  30. package/dist/cjs/src/server/puppet-implementation.d.ts.map +1 -1
  31. package/dist/cjs/src/server/puppet-implementation.js +135 -28
  32. package/dist/cjs/src/server/puppet-implementation.js.map +1 -1
  33. package/dist/cjs/src/server/puppet-implementation.spec.js +98 -0
  34. package/dist/cjs/src/server/puppet-implementation.spec.js.map +1 -1
  35. package/dist/cjs/src/utils/pb-payload-helper.d.ts +10 -0
  36. package/dist/cjs/src/utils/pb-payload-helper.d.ts.map +1 -1
  37. package/dist/cjs/src/utils/pb-payload-helper.js +180 -1
  38. package/dist/cjs/src/utils/pb-payload-helper.js.map +1 -1
  39. package/dist/cjs/src/utils/room-member-load.spec.d.ts +2 -0
  40. package/dist/cjs/src/utils/room-member-load.spec.d.ts.map +1 -0
  41. package/dist/cjs/src/utils/room-member-load.spec.js +83 -0
  42. package/dist/cjs/src/utils/room-member-load.spec.js.map +1 -0
  43. package/dist/esm/src/auth/call-cred.d.ts +2 -2
  44. package/dist/esm/src/auth/call-cred.d.ts.map +1 -1
  45. package/dist/esm/src/auth/call-cred.js.map +1 -1
  46. package/dist/esm/src/client/puppet-service.d.ts +27 -2
  47. package/dist/esm/src/client/puppet-service.d.ts.map +1 -1
  48. package/dist/esm/src/client/puppet-service.js +432 -125
  49. package/dist/esm/src/client/puppet-service.js.map +1 -1
  50. package/dist/esm/src/client/puppet-service.spec.js +107 -0
  51. package/dist/esm/src/client/puppet-service.spec.js.map +1 -1
  52. package/dist/esm/src/client/room-cache-coordinator.d.ts +14 -0
  53. package/dist/esm/src/client/room-cache-coordinator.d.ts.map +1 -0
  54. package/dist/esm/src/client/room-cache-coordinator.js +49 -0
  55. package/dist/esm/src/client/room-cache-coordinator.js.map +1 -0
  56. package/dist/esm/src/client/room-member-cache.spec.d.ts +2 -0
  57. package/dist/esm/src/client/room-member-cache.spec.d.ts.map +1 -0
  58. package/dist/esm/src/client/room-member-cache.spec.js +415 -0
  59. package/dist/esm/src/client/room-member-cache.spec.js.map +1 -0
  60. package/dist/esm/src/client/room-member-load.spec.d.ts +2 -0
  61. package/dist/esm/src/client/room-member-load.spec.d.ts.map +1 -0
  62. package/dist/esm/src/client/room-member-load.spec.js +296 -0
  63. package/dist/esm/src/client/room-member-load.spec.js.map +1 -0
  64. package/dist/esm/src/package-json.js +4 -4
  65. package/dist/esm/src/server/event-stream-manager.d.ts.map +1 -1
  66. package/dist/esm/src/server/event-stream-manager.js +30 -0
  67. package/dist/esm/src/server/event-stream-manager.js.map +1 -1
  68. package/dist/esm/src/server/event-stream-manager.spec.d.ts +3 -0
  69. package/dist/esm/src/server/event-stream-manager.spec.d.ts.map +1 -0
  70. package/dist/esm/src/server/event-stream-manager.spec.js +20 -0
  71. package/dist/esm/src/server/event-stream-manager.spec.js.map +1 -0
  72. package/dist/esm/src/server/puppet-implementation.d.ts.map +1 -1
  73. package/dist/esm/src/server/puppet-implementation.js +136 -29
  74. package/dist/esm/src/server/puppet-implementation.js.map +1 -1
  75. package/dist/esm/src/server/puppet-implementation.spec.js +98 -0
  76. package/dist/esm/src/server/puppet-implementation.spec.js.map +1 -1
  77. package/dist/esm/src/utils/pb-payload-helper.d.ts +10 -0
  78. package/dist/esm/src/utils/pb-payload-helper.d.ts.map +1 -1
  79. package/dist/esm/src/utils/pb-payload-helper.js +169 -0
  80. package/dist/esm/src/utils/pb-payload-helper.js.map +1 -1
  81. package/dist/esm/src/utils/room-member-load.spec.d.ts +2 -0
  82. package/dist/esm/src/utils/room-member-load.spec.d.ts.map +1 -0
  83. package/dist/esm/src/utils/room-member-load.spec.js +58 -0
  84. package/dist/esm/src/utils/room-member-load.spec.js.map +1 -0
  85. package/package.json +4 -4
  86. package/src/auth/call-cred.ts +1 -1
  87. package/src/client/puppet-service.spec.ts +147 -0
  88. package/src/client/puppet-service.ts +378 -138
  89. package/src/client/room-cache-coordinator.ts +50 -0
  90. package/src/client/room-member-cache.spec.ts +448 -0
  91. package/src/client/room-member-load.spec.ts +319 -0
  92. package/src/package-json.ts +4 -4
  93. package/src/server/event-stream-manager.spec.ts +30 -0
  94. package/src/server/event-stream-manager.ts +29 -0
  95. package/src/server/puppet-implementation.spec.ts +127 -0
  96. package/src/server/puppet-implementation.ts +144 -29
  97. package/src/utils/pb-payload-helper.ts +150 -0
  98. package/src/utils/room-member-load.spec.ts +67 -0
@@ -0,0 +1,50 @@
1
+ class RoomCacheOperationError extends Error {
2
+
3
+ constructor (roomId: string, readonly original: unknown) {
4
+ super(`Room cache ${roomId} failed: ${original instanceof Error ? original.message : String(original)}`)
5
+ this.name = 'RoomCacheOperationError'
6
+ }
7
+
8
+ }
9
+
10
+ class RoomCacheCoordinator {
11
+
12
+ private readonly generations = new Map<string, number>()
13
+ private readonly pending = new Map<string, Promise<void>>()
14
+
15
+ generation (roomId: string): number {
16
+ return this.generations.get(roomId) ?? 0
17
+ }
18
+
19
+ invalidate (roomId: string): void {
20
+ this.generations.set(roomId, this.generation(roomId) + 1)
21
+ }
22
+
23
+ async run<Result> (roomId: string, operation: () => Promise<Result>): Promise<Result> {
24
+ const preceding = this.pending.get(roomId) ?? Promise.resolve()
25
+ const result = (async (): Promise<Result> => {
26
+ await preceding
27
+ return operation()
28
+ })()
29
+ // Only the queue barrier settles errors; the caller still receives the rejected result.
30
+ const barrier = result.then((): undefined => undefined, (): undefined => undefined)
31
+ this.pending.set(roomId, barrier)
32
+ try {
33
+ return await result
34
+ } catch (error) {
35
+ throw error instanceof RoomCacheOperationError ? error : new RoomCacheOperationError(roomId, error)
36
+ } finally {
37
+ if (this.pending.get(roomId) === barrier) { this.pending.delete(roomId) }
38
+ }
39
+ }
40
+
41
+ async commit (roomId: string, generation: number, operation: () => Promise<unknown>): Promise<void> {
42
+ await this.run(roomId, async (): Promise<void> => {
43
+ if (this.generation(roomId) !== generation) { return }
44
+ await operation()
45
+ })
46
+ }
47
+
48
+ }
49
+
50
+ export { RoomCacheCoordinator, RoomCacheOperationError }
@@ -0,0 +1,448 @@
1
+ import { test, sinon } from 'tstest'
2
+ import { puppet as grpcPuppet } from '@juzi/wechaty-grpc'
3
+ import type * as PUPPET from '@juzi/wechaty-puppet'
4
+
5
+ import { PuppetService } from './puppet-service.js'
6
+ import { GrpcManager } from './grpc-manager.js'
7
+ import { roomPayloadToPb, roomMemberPayloadToPb } from '../utils/pb-payload-helper.js'
8
+
9
+ interface Deferred<Value> {
10
+ promise: Promise<Value>
11
+ resolve: (value: Value) => void
12
+ reject: (reason: unknown) => void
13
+ }
14
+
15
+ interface ResetFixture {
16
+ service: PuppetService
17
+ manager: GrpcManager
18
+ metadata: Map<string, string>
19
+ started: Deferred<void>
20
+ release: Deferred<void>
21
+ stop: { readonly callCount: number }
22
+ start: { readonly callCount: number, readonly args: readonly unknown[][] }
23
+ }
24
+
25
+ function deferred<Value> (): Deferred<Value> {
26
+ let finish: (value: Value) => void = (): void => { throw new Error('Uninitialized resolver') }
27
+ let fail: (reason: unknown) => void = (): void => { throw new Error('Uninitialized rejection') }
28
+ const promise = new Promise<Value>((resolve, reject) => { finish = resolve; fail = reject })
29
+ return { promise, resolve: finish, reject: fail }
30
+ }
31
+
32
+ class BridgedService extends PuppetService {
33
+
34
+ connect (manager: GrpcManager): void { this._grpcManager = manager; this.bridgeGrpcEventStream(manager) }
35
+
36
+ }
37
+
38
+ let fixtureId = 0
39
+ function serviceFixture (): BridgedService {
40
+ return new BridgedService({ token: `insecure_cache-${process.pid}-${fixtureId++}` })
41
+ }
42
+
43
+ function event (payload: unknown, seq = 2): grpcPuppet.EventResponse {
44
+ const response = new grpcPuppet.EventResponse()
45
+ response.setType(grpcPuppet.EventType.EVENT_TYPE_ROOM_MEMBER_LOAD)
46
+ response.setSeq(seq)
47
+ response.setPayload(JSON.stringify(payload))
48
+ return response
49
+ }
50
+
51
+ const state = { roomId: 'room', taskId: 'task', status: 3, revision: '1' }
52
+
53
+ async function drain (): Promise<void> {
54
+ await new Promise<void>(resolve => setImmediate(resolve))
55
+ }
56
+
57
+ function resetFixture (remove: () => Promise<void> = async (): Promise<void> => {}): ResetFixture {
58
+ const service = serviceFixture()
59
+ const manager = new GrpcManager({ token: 'insecure_token' })
60
+ const metadata = new Map([ [ 'lastEventSeq', '1' ], [ 'lastEventTimestamp', String(Date.now()) ], [ 'accountId', 'account' ] ])
61
+ Object.defineProperty(service, '_payloadStore', {
62
+ value: {
63
+ room: { delete: remove },
64
+ roomMember: { delete: async (): Promise<void> => {} },
65
+ miscellaneous: {
66
+ get: async (key: string): Promise<string | undefined> => metadata.get(key),
67
+ set: async (key: string, value: string): Promise<void> => { metadata.set(key, value) },
68
+ delete: async (key: string): Promise<void> => { metadata.delete(key) },
69
+ },
70
+ },
71
+ })
72
+ Object.defineProperty(service, 'isLoggedIn', { get: (): boolean => true })
73
+ sinon.stub(service, 'login').resolves()
74
+ const stop = sinon.stub(manager, 'stopStream')
75
+ const started = deferred<void>()
76
+ const release = deferred<void>()
77
+ const start = sinon.stub(manager, 'startStream').callsFake(async (): Promise<void> => {
78
+ started.resolve()
79
+ await release.promise
80
+ })
81
+ service.connect(manager)
82
+ return { service, manager, metadata, stop, start, started, release }
83
+ }
84
+
85
+ async function finishResetHandshake (fixture: ResetFixture): Promise<void> {
86
+ fixture.release.resolve()
87
+ await drain()
88
+ const login = new grpcPuppet.EventResponse()
89
+ login.setType(grpcPuppet.EventType.EVENT_TYPE_LOGIN)
90
+ login.setPayload(JSON.stringify({ contactId: 'account' }))
91
+ fixture.manager.emit('data', login)
92
+ const ready = new grpcPuppet.EventResponse()
93
+ ready.setType(grpcPuppet.EventType.EVENT_TYPE_READY)
94
+ ready.setPayload(JSON.stringify({ data: 'ready' }))
95
+ fixture.manager.emit('data', ready)
96
+ await drain()
97
+ }
98
+
99
+ test('bridge cache failure stops the stream and fences later seq until explicit reset', async t => {
100
+ const service = serviceFixture()
101
+ const manager = new GrpcManager({ token: 'insecure_token' })
102
+ const metadata = new Map<string, string>([ [ 'lastEventSeq', '1' ], [ 'lastEventTimestamp', String(Date.now()) ] ])
103
+ const deleteRoom = sinon.stub().rejects(new Error('persistent cache unavailable'))
104
+ Object.defineProperty(service, '_payloadStore', {
105
+ value: {
106
+ room: { delete: deleteRoom },
107
+ roomMember: { delete: sinon.stub().resolves() },
108
+ miscellaneous: {
109
+ get: async (key: string): Promise<string | undefined> => metadata.get(key),
110
+ set: async (key: string, value: string): Promise<void> => { metadata.set(key, value) },
111
+ },
112
+ },
113
+ })
114
+ const stop = sinon.stub(manager, 'stopStream')
115
+ const start = sinon.stub(manager, 'startStream').resolves()
116
+ const errors = sinon.spy()
117
+ const completed = sinon.spy()
118
+ const unhandled = sinon.spy()
119
+ process.on('unhandledRejection', unhandled)
120
+ t.teardown((): void => { process.off('unhandledRejection', unhandled) })
121
+ service.on('error', errors)
122
+ service.on('room-member-load', completed)
123
+ service.connect(manager)
124
+ manager.emit('data', event(state))
125
+ manager.emit('data', event({ ...state, revision: '2' }, 3))
126
+ await drain()
127
+ t.equal(unhandled.callCount, 0)
128
+ t.equal(errors.callCount, 1)
129
+ t.equal(completed.callCount, 0)
130
+ t.equal(metadata.get('lastEventSeq'), '1')
131
+ t.equal(stop.callCount, 1)
132
+ t.equal(start.callCount, 0)
133
+ deleteRoom.resolves()
134
+ manager.emit('data', event(state))
135
+ await drain()
136
+ t.equal(completed.callCount, 0)
137
+ t.equal(metadata.get('lastEventSeq'), '1')
138
+ })
139
+
140
+ test('invalid event payloads never reach persistent cache or emit completion', async t => {
141
+ for (const payload of [
142
+ { ...state, revision: 1 }, { ...state, revision: '01' }, { ...state, status: 99 },
143
+ { ...state, roomId: '' }, { ...state, roomId: undefined }, { ...state, taskId: 1 },
144
+ ]) {
145
+ const service = serviceFixture()
146
+ const manager = new GrpcManager({ token: 'insecure_token' })
147
+ const remove = sinon.stub().resolves()
148
+ Object.defineProperty(service, '_payloadStore', { value: { room: { delete: remove }, roomMember: { delete: remove } } })
149
+ sinon.stub(manager, 'stopStream')
150
+ sinon.stub(manager, 'startStream').resolves()
151
+ const errors = sinon.spy()
152
+ const completed = sinon.spy()
153
+ service.on('error', errors)
154
+ service.on('room-member-load', completed)
155
+ service.connect(manager)
156
+ manager.emit('data', event(payload))
157
+ await drain()
158
+ t.equal(remove.callCount, 0)
159
+ t.equal(completed.callCount, 0)
160
+ t.equal(errors.callCount, 1)
161
+ }
162
+ })
163
+
164
+ test('all four raw RPC reads begun before invalidation cannot refill persistent caches', async t => {
165
+ const room: PUPPET.payloads.Room = { id: 'room', topic: 'old', adminIdList: [], memberIdList: [] }
166
+ const member: PUPPET.payloads.RoomMember = { id: 'member', name: 'old', avatar: '' }
167
+ for (const method of [ 'room', 'batchRoom', 'member', 'batchMember' ]) {
168
+ const responseReady = deferred<void>()
169
+ const requested = deferred<void>()
170
+ const service = serviceFixture()
171
+ const manager = new GrpcManager({ token: 'insecure_token' })
172
+ const rooms = new Map<string, PUPPET.payloads.Room>()
173
+ const members = new Map<string, Record<string, PUPPET.payloads.RoomMember>>()
174
+ Object.defineProperty(service, '_payloadStore', {
175
+ value: {
176
+ room: {
177
+ get: async (id: string): Promise<PUPPET.payloads.Room | undefined> => rooms.get(id),
178
+ set: async (id: string, value: PUPPET.payloads.Room): Promise<void> => { rooms.set(id, value) },
179
+ delete: async (id: string): Promise<void> => { rooms.delete(id) },
180
+ },
181
+ roomMember: {
182
+ get: async (id: string): Promise<Record<string, PUPPET.payloads.RoomMember> | undefined> => members.get(id),
183
+ set: async (id: string, value: Record<string, PUPPET.payloads.RoomMember>): Promise<void> => { members.set(id, value) },
184
+ delete: async (id: string): Promise<void> => { members.delete(id) },
185
+ },
186
+ },
187
+ })
188
+ const wait = async (): Promise<void> => { requested.resolve(); await responseReady.promise }
189
+ const respond = async <Response>(response: Response, callback: (error: Error | null, value: Response) => void): Promise<void> => {
190
+ await wait()
191
+ callback(null, response)
192
+ }
193
+ const client = {
194
+ roomPayload: (_request: grpcPuppet.RoomPayloadRequest, callback: (error: Error | null, response: grpcPuppet.RoomPayloadResponse) => void): void => {
195
+ void respond(roomPayloadToPb(room), callback)
196
+ },
197
+ batchRoomPayload: (_request: grpcPuppet.BatchRoomPayloadRequest, callback: (error: Error | null, response: grpcPuppet.BatchRoomPayloadResponse) => void): void => {
198
+ const response = new grpcPuppet.BatchRoomPayloadResponse()
199
+ response.setRoomPayloadsList([ roomPayloadToPb(room) ])
200
+ void respond(response, callback)
201
+ },
202
+ roomMemberPayload: (_request: grpcPuppet.RoomMemberPayloadRequest, callback: (error: Error | null, response: grpcPuppet.RoomMemberPayloadResponse) => void): void => {
203
+ void respond(roomMemberPayloadToPb(member), callback)
204
+ },
205
+ batchRoomMemberPayload: (_request: grpcPuppet.BatchRoomMemberPayloadRequest, callback: (error: Error | null, response: grpcPuppet.BatchRoomMemberPayloadResponse) => void): void => {
206
+ const response = new grpcPuppet.BatchRoomMemberPayloadResponse()
207
+ response.setMemberPayloadsList([ roomMemberPayloadToPb(member) ])
208
+ void respond(response, callback)
209
+ },
210
+ }
211
+ Object.defineProperty(service, 'grpcManager', { value: { client } })
212
+ service.on('error', (error: unknown): void => { t.fail(String(error)) })
213
+ service.connect(manager)
214
+ const pending = method === 'room' ? service.roomRawPayload('room')
215
+ : method === 'batchRoom' ? service.batchRoomRawPayload([ 'room' ])
216
+ : method === 'member' ? service.roomMemberRawPayload('room', 'member')
217
+ : service.batchRoomMemberRawPayload('room', [ 'member' ])
218
+ await requested.promise
219
+ manager.emit('data', event(state, 0))
220
+ await drain()
221
+ responseReady.resolve()
222
+ await pending
223
+ t.equal(rooms.size, 0, method)
224
+ t.equal(members.size, 0, method)
225
+ }
226
+ })
227
+
228
+ test('invalidation waits for an already-started asynchronous cache write', async t => {
229
+ const service = serviceFixture()
230
+ const manager = new GrpcManager({ token: 'insecure_token' })
231
+ const writing = deferred<void>()
232
+ const finishWrite = deferred<void>()
233
+ let stored: PUPPET.payloads.Room | undefined
234
+ const room: PUPPET.payloads.Room = { id: 'room', topic: 'old', adminIdList: [], memberIdList: [] }
235
+ const remove = sinon.stub().callsFake(async (): Promise<void> => { stored = undefined })
236
+ Object.defineProperty(service, '_payloadStore', {
237
+ value: {
238
+ room: {
239
+ get: async (): Promise<PUPPET.payloads.Room | undefined> => stored,
240
+ set: async (_id: string, value: PUPPET.payloads.Room): Promise<void> => {
241
+ writing.resolve()
242
+ await finishWrite.promise
243
+ stored = value
244
+ },
245
+ delete: remove,
246
+ },
247
+ },
248
+ })
249
+ Object.defineProperty(service, 'grpcManager', {
250
+ value: {
251
+ client: {
252
+ roomPayload: (_request: grpcPuppet.RoomPayloadRequest, callback: (error: Error | null, response: grpcPuppet.RoomPayloadResponse) => void): void => callback(null, roomPayloadToPb(room)),
253
+ },
254
+ },
255
+ })
256
+ service.connect(manager)
257
+ const loaded = sinon.spy()
258
+ service.on('room-member-load', loaded)
259
+ const pending = service.roomRawPayload('room')
260
+ await writing.promise
261
+ manager.emit('data', event(state, 0))
262
+ await drain()
263
+ t.equal(remove.callCount, 0)
264
+ t.equal(loaded.callCount, 0)
265
+ finishWrite.resolve()
266
+ await pending
267
+ await drain()
268
+ t.equal(stored, undefined)
269
+ t.equal(loaded.callCount, 1)
270
+ })
271
+
272
+ test('later events cannot bypass a halted failure and never cause automatic restart', async t => {
273
+ for (const skipped of [ false, true ]) {
274
+ const service = serviceFixture()
275
+ const manager = new GrpcManager({ token: 'insecure_token' })
276
+ const metadata = new Map<string, string>([ [ 'lastEventSeq', '1' ] ])
277
+ Object.defineProperty(service, '_payloadStore', {
278
+ value: {
279
+ room: { delete: sinon.stub().rejects(new Error('persistent cache unavailable')) },
280
+ miscellaneous: {
281
+ get: async (key: string): Promise<string | undefined> => metadata.get(key),
282
+ set: async (key: string, value: string): Promise<void> => { metadata.set(key, value) },
283
+ },
284
+ },
285
+ })
286
+ sinon.stub(manager, 'stopStream')
287
+ const start = sinon.stub(manager, 'startStream').resolves()
288
+ const errors = sinon.spy()
289
+ const completed = sinon.spy()
290
+ service.on('error', errors)
291
+ service.on('room-member-load', completed)
292
+ service.connect(manager)
293
+ manager.emit('data', event(state))
294
+ await drain()
295
+ manager.emit('data', event(state, skipped ? 3 : 2))
296
+ manager.emit('data', event(state, 4))
297
+ await drain()
298
+ t.equal(start.callCount, 0)
299
+ t.equal(errors.callCount, 1)
300
+ t.equal(completed.callCount, 0)
301
+ t.equal(metadata.get('lastEventSeq'), '1')
302
+ }
303
+ })
304
+
305
+ test('duplicate reset does not invalidate events queued by the owner reset', async t => {
306
+ const fixture = resetFixture()
307
+ const completed = sinon.spy()
308
+ fixture.service.on('room-member-load', completed)
309
+ const resetting = fixture.service.reset()
310
+ await fixture.started.promise
311
+ fixture.release.resolve()
312
+ await drain()
313
+ const login = new grpcPuppet.EventResponse()
314
+ login.setType(grpcPuppet.EventType.EVENT_TYPE_LOGIN)
315
+ login.setPayload(JSON.stringify({ contactId: 'account' }))
316
+ fixture.manager.emit('data', login)
317
+ await drain()
318
+ fixture.manager.emit('data', event(state))
319
+ await fixture.service.reset()
320
+ await drain()
321
+ t.equal(completed.callCount, 1)
322
+ t.equal(fixture.metadata.get('lastEventSeq'), '2')
323
+ t.equal(fixture.start.callCount, 1)
324
+ await finishResetHandshake(fixture)
325
+ await resetting
326
+ })
327
+
328
+ test('superseded delete rejection cannot stop the stream established by reset', async t => {
329
+ const removing = deferred<void>()
330
+ const began = deferred<void>()
331
+ const remove = sinon.stub().onFirstCall().callsFake(async (): Promise<void> => { began.resolve(); await removing.promise })
332
+ remove.onSecondCall().resolves()
333
+ const fixture = resetFixture(remove)
334
+ const errors = sinon.spy()
335
+ const completed = sinon.spy()
336
+ fixture.service.on('error', errors)
337
+ fixture.service.on('room-member-load', completed)
338
+ fixture.manager.emit('data', event(state))
339
+ await began.promise
340
+ const resetting = fixture.service.reset()
341
+ await fixture.started.promise
342
+ await finishResetHandshake(fixture)
343
+ await resetting
344
+ removing.reject(new Error('old delete rejected after reset'))
345
+ await drain()
346
+ t.equal(fixture.stop.callCount, 1)
347
+ t.equal(fixture.start.callCount, 1)
348
+ t.equal(errors.callCount, 0)
349
+ t.equal(completed.callCount, 0)
350
+ t.equal(fixture.metadata.get('lastEventSeq'), '1')
351
+ fixture.manager.emit('data', event(state))
352
+ await drain()
353
+ t.equal(completed.callCount, 1)
354
+ t.equal(fixture.metadata.get('lastEventSeq'), '2')
355
+ })
356
+
357
+ test('failure stops before notifying an error listener that immediately performs actual reset', async t => {
358
+ const remove = sinon.stub().onFirstCall().rejects(new Error('persistent cache unavailable'))
359
+ remove.onSecondCall().resolves()
360
+ const fixture = resetFixture(remove)
361
+ const resetting = deferred<Promise<void>>()
362
+ const stopsAtError: number[] = []
363
+ fixture.service.on('error', (): void => {
364
+ stopsAtError.push(fixture.stop.callCount)
365
+ resetting.resolve(fixture.service.reset())
366
+ })
367
+ fixture.manager.emit('data', event(state))
368
+ await fixture.started.promise
369
+ t.same(stopsAtError, [ 1 ])
370
+ t.equal(fixture.start.callCount, 1)
371
+ t.same(fixture.start.args[0], [ '1', 'account' ])
372
+ await finishResetHandshake(fixture)
373
+ await resetting.promise
374
+ await drain()
375
+ t.equal(fixture.stop.callCount, 2)
376
+ t.equal(fixture.start.callCount, 1)
377
+ fixture.manager.emit('data', event(state))
378
+ await drain()
379
+ t.equal(fixture.metadata.get('lastEventSeq'), '2')
380
+ })
381
+
382
+ test('reset waits for and rolls back a superseded asynchronous cursor commit before resuming', async t => {
383
+ const fixture = resetFixture()
384
+ const writing = deferred<void>()
385
+ const finishWrite = deferred<void>()
386
+ Object.defineProperty(fixture.service, '_payloadStore', {
387
+ value: {
388
+ room: { delete: async (): Promise<void> => {} },
389
+ roomMember: { delete: async (): Promise<void> => {} },
390
+ miscellaneous: {
391
+ get: async (key: string): Promise<string | undefined> => fixture.metadata.get(key),
392
+ set: async (key: string, value: string): Promise<void> => {
393
+ if (key === 'lastEventSeq' && value === '2') { writing.resolve(); await finishWrite.promise }
394
+ fixture.metadata.set(key, value)
395
+ },
396
+ delete: async (key: string): Promise<void> => { fixture.metadata.delete(key) },
397
+ },
398
+ },
399
+ })
400
+ fixture.manager.emit('data', event(state))
401
+ await writing.promise
402
+ const resetting = fixture.service.reset()
403
+ await drain()
404
+ t.equal(fixture.start.callCount, 0)
405
+ finishWrite.resolve()
406
+ await fixture.started.promise
407
+ t.same(fixture.start.args[0], [ '1', 'account' ])
408
+ t.equal(fixture.metadata.get('lastEventSeq'), '1')
409
+ await finishResetHandshake(fixture)
410
+ await resetting
411
+ })
412
+
413
+ test('explicit reset preserves the failed seq barrier if the new stream skips that event', async t => {
414
+ const remove = sinon.stub().onFirstCall().rejects(new Error('persistent cache unavailable'))
415
+ remove.onSecondCall().resolves()
416
+ const fixture = resetFixture(remove)
417
+ const errors = sinon.spy()
418
+ const completed = sinon.spy()
419
+ fixture.service.on('error', errors)
420
+ fixture.service.on('room-member-load', completed)
421
+ fixture.manager.emit('data', event(state))
422
+ await drain()
423
+ t.equal(fixture.start.callCount, 0)
424
+ const resetting = fixture.service.reset()
425
+ await fixture.started.promise
426
+ await finishResetHandshake(fixture)
427
+ await resetting
428
+ fixture.manager.emit('data', event(state, 3))
429
+ await drain()
430
+ t.equal(errors.callCount, 2)
431
+ t.equal(completed.callCount, 0)
432
+ t.equal(fixture.metadata.get('lastEventSeq'), '1')
433
+ t.equal(fixture.start.callCount, 1)
434
+ })
435
+
436
+ test('explicit reset uses the actual durable cursor without age fallback or invented zero', async t => {
437
+ for (const absent of [ false, true ]) {
438
+ const fixture = resetFixture()
439
+ fixture.metadata.set('lastEventTimestamp', '0')
440
+ if (absent) { fixture.metadata.delete('lastEventSeq') }
441
+ const resetting = fixture.service.reset()
442
+ await fixture.started.promise
443
+ t.same(fixture.start.args[0], [ absent ? undefined : '1', 'account' ])
444
+ await finishResetHandshake(fixture)
445
+ await resetting
446
+ t.equal(fixture.metadata.get('lastEventSeq'), absent ? undefined : '1')
447
+ }
448
+ })