wenay-common2 1.0.69 → 1.0.73

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 (89) hide show
  1. package/README.md +11 -1
  2. package/demo/client.ts +222 -0
  3. package/demo/index.html +58 -0
  4. package/demo/server.ts +93 -0
  5. package/doc/INTENT.md +31 -0
  6. package/doc/ROADMAP.md +19 -1
  7. package/doc/changes/1.0.70.md +4 -0
  8. package/doc/changes/1.0.71.md +9 -0
  9. package/doc/changes/1.0.72.md +6 -0
  10. package/doc/changes/1.0.73.md +6 -0
  11. package/doc/wenay-common2-rare.md +29 -8
  12. package/doc/wenay-common2.md +13 -2
  13. package/lib/Common/media/media-index.d.ts +1 -0
  14. package/lib/Common/media/media-index.js +1 -0
  15. package/lib/Common/media/media-source.d.ts +3 -0
  16. package/lib/Common/media/media-source.js +136 -27
  17. package/lib/Common/media/media-view.d.ts +42 -0
  18. package/lib/Common/media/media-view.js +191 -0
  19. package/lib/Common/peer/peer-client.d.ts +12 -5
  20. package/lib/Common/peer/peer-client.js +53 -3
  21. package/lib/Common/peer/peer-host.d.ts +8 -4
  22. package/lib/Common/peer/peer-host.js +2 -2
  23. package/lib/Common/peer/peer-relay.d.ts +10 -2
  24. package/lib/Common/peer/peer-relay.js +40 -18
  25. package/observe/PLAN.md +131 -0
  26. package/observe/README.md +226 -0
  27. package/observe/hot-write.test.ts +95 -0
  28. package/observe/listen-core.test.ts +66 -0
  29. package/observe/listen-store.test.ts +56 -0
  30. package/observe/listen.test.ts +92 -0
  31. package/observe/reactive.test.ts +270 -0
  32. package/observe/reactive.ts +1 -0
  33. package/observe/store-manager.test.ts +118 -0
  34. package/observe/store-mirror.example.ts +74 -0
  35. package/observe/store.test.ts +235 -0
  36. package/observe/store.ts +1 -0
  37. package/observe/usage-real-socket.ts +174 -0
  38. package/observe/usage.ts +200 -0
  39. package/oracle/PASSED.md +27 -0
  40. package/oracle/README.md +12 -0
  41. package/oracle/fixes-primitives.spec.ts +90 -0
  42. package/oracle/realsocket/_rs.ts +106 -0
  43. package/oracle/realsocket/auth.spec.ts +91 -0
  44. package/oracle/realsocket/callbacks.spec.ts +122 -0
  45. package/oracle/realsocket/caps.spec.ts +124 -0
  46. package/oracle/realsocket/core.spec.ts +49 -0
  47. package/oracle/realsocket/dedupe.spec.ts +142 -0
  48. package/oracle/realsocket/errors.spec.ts +121 -0
  49. package/oracle/realsocket/lifecycle.spec.ts +128 -0
  50. package/oracle/realsocket/limits.spec.ts +98 -0
  51. package/oracle/realsocket/pipe.spec.ts +101 -0
  52. package/oracle/realsocket/shape.spec.ts +124 -0
  53. package/oracle/realsocket/slimv2.spec.ts +116 -0
  54. package/oracle/realsocket/stress.spec.ts +132 -0
  55. package/oracle/regression/_clientapiall-replay.fixture.ts +57 -0
  56. package/oracle/regression/async-queues.spec.ts +254 -0
  57. package/oracle/regression/bytestream.spec.ts +152 -0
  58. package/oracle/regression/clientapiall-replay-types.spec.ts +47 -0
  59. package/oracle/regression/core-clone-equal.spec.ts +124 -0
  60. package/oracle/regression/data-structures.spec.ts +135 -0
  61. package/oracle/regression/listen-events.spec.ts +206 -0
  62. package/oracle/regression/observe-core.spec.ts +278 -0
  63. package/oracle/regression/package-export.spec.ts +120 -0
  64. package/oracle/regression/rpc-dedupe-callbacks.spec.ts +195 -0
  65. package/oracle/regression/rpc-lifecycle.spec.ts +150 -0
  66. package/oracle/regression/store-each.spec.ts +209 -0
  67. package/package.json +6 -1
  68. package/replay/PLAN.md +171 -0
  69. package/replay/canvas-socket.test.ts +187 -0
  70. package/replay/coalesce.test.ts +260 -0
  71. package/replay/conflate-socket.test.ts +288 -0
  72. package/replay/conflate.test.ts +225 -0
  73. package/replay/history.test.ts +222 -0
  74. package/replay/media-socket.test.ts +157 -0
  75. package/replay/media-view.test.ts +164 -0
  76. package/replay/offline-store-socket.test.ts +290 -0
  77. package/replay/offline-store.test.ts +156 -0
  78. package/replay/peer-repair.test.ts +184 -0
  79. package/replay/peer-sdk.test.ts +228 -0
  80. package/replay/replay-listen.test.ts +156 -0
  81. package/replay/route-coordinator.test.ts +314 -0
  82. package/replay/route-handoff.test.ts +150 -0
  83. package/replay/route-webrtc.test.ts +321 -0
  84. package/replay/rpc-auto.test.ts +256 -0
  85. package/replay/socket-replay.test.ts +199 -0
  86. package/replay/staleness.test.ts +176 -0
  87. package/replay/store-replay.test.ts +151 -0
  88. package/replay/video-socket.demo.ts +200 -0
  89. package/doc/changes/1.0.63.md +0 -8
@@ -0,0 +1,288 @@
1
+ // ============================================================
2
+ // replay/conflate-socket.test.ts
3
+ //
4
+ // Слой D.1 через НАСТОЯЩИЙ Socket.IO: per-connection conflation
5
+ // + snapshot recovery. Ворота строятся на каждое соединение;
6
+ // заполненность буфера симулируется (управляется тестом через
7
+ // debug-метод) — в проде это socket.conn.writeBuffer.length.
8
+ // Сценарии: keyframe recovery; keyOf-хвост и maxKeys-деградация
9
+ // по проводу; два клиента на одной линии (медленный + быстрый);
10
+ // реконнект ПОСРЕДИ conflation-эпизода через since (журнал полон,
11
+ // дропы ворот его не дырявят).
12
+ // Запуск:
13
+ // npx ts-node replay/conflate-socket.test.ts
14
+ // ============================================================
15
+
16
+ import express from 'express'
17
+ import {createServer} from 'http'
18
+ import type {AddressInfo} from 'net'
19
+ import {Server as SocketIOServer} from 'socket.io'
20
+ import {io} from 'socket.io-client'
21
+ import {listen as createListenPair} from '../src/Common/events/Listen'
22
+ import {createRpcClientHub} from '../src/Common/rcp/rpc-clientHub'
23
+ import {createRpcServerAuto} from '../src/Common/rcp/rpc-server-auto'
24
+ import {createStore, StorePatch} from '../src/Common/Observe/store'
25
+ import {flushReactive} from '../src/Common/Observe/reactive'
26
+ import {exposeStoreReplay, syncStoreReplay, storePatchKey} from '../src/Common/Observe/store-replay'
27
+ import {conflateReplay, exposeReplay} from '../src/Common/events/replay-index'
28
+ import {ReplayRemote} from '../src/Common/events/replay-index'
29
+
30
+ let fails = 0
31
+ const ok = (condition: any, message: string) => {
32
+ if (!condition) { fails++; console.log(' FAIL', message) }
33
+ else console.log(' OK ', message)
34
+ }
35
+ const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
36
+ const json = (v: any) => JSON.stringify(v)
37
+
38
+ async function waitFor(label: string, cond: () => boolean) {
39
+ for (let i = 0; i < 120; i++) {
40
+ if (cond()) return
41
+ await delay(25)
42
+ }
43
+ throw new Error(`timeout: ${label}`)
44
+ }
45
+
46
+ type World = {
47
+ units: Record<string, {hp: number, x: number}>
48
+ tick: number
49
+ }
50
+
51
+ type ConflateStats = {conflating: boolean, dropped: number, keyframes: number, coalesced: number, flushes: number}
52
+
53
+ async function main() {
54
+ console.log('\n[conflate-socket] per-connection conflation over a real Socket.IO wire')
55
+
56
+ const backend = createStore<World>({units: {alpha: {hp: 100, x: 0}, beta: {hp: 100, x: 0}, gamma: {hp: 100, x: 0}}, tick: 0}, {drain: 'micro'})
57
+ const exposed = exposeStoreReplay(backend, {history: 1000})
58
+
59
+ // ============ сервер: ворота на КАЖДОЕ соединение ============
60
+ const app = express()
61
+ const httpServer = createServer(app)
62
+ const ioServer = new SocketIOServer(httpServer, {maxHttpBufferSize: 1e8})
63
+ ioServer.on('connection', socket => {
64
+ const buf = {v: 0} // симулированный исходящий буфер ЭТОГО клиента
65
+ const bufK = {v: 0} // отдельный буфер линии с keyOf-схлопыванием
66
+ // однострочная форма ворот (паритет с ручным conflateReplay — gatedK ниже):
67
+ // close/stats деструктурируются В СТОРОНУ и в rpc-объект не попадают
68
+ const {close: gatedClose, stats: gatedStats, ...gatedApi} =
69
+ exposeReplay(exposed.replay, {conflate: {pending: () => buf.v, highWater: 5, pollMs: 10}})
70
+ const gatedK = conflateReplay(exposed.replay, {
71
+ pending: () => bufK.v, highWater: 5, pollMs: 10,
72
+ keyOf: storePatchKey, maxKeys: 4,
73
+ })
74
+ const [disconnect, disconnectListen] = createListenPair<[]>()
75
+ socket.on('disconnect', () => { disconnect(); gatedClose(); gatedK.close() })
76
+ createRpcServerAuto({
77
+ socket: {
78
+ emit: (key, data) => socket.emit(key, data),
79
+ on: (key, cb) => socket.on(key, cb),
80
+ },
81
+ socketKey: 'conflate-store',
82
+ object: {
83
+ ...exposed.api,
84
+ replay: gatedApi,
85
+ replayK: gatedK.api,
86
+ debug: {
87
+ setPending: (n: number) => { buf.v = n },
88
+ setPendingK: (n: number) => { bufK.v = n },
89
+ stats: () => gatedStats(),
90
+ statsK: () => gatedK.stats(),
91
+ },
92
+ },
93
+ disconnectListen,
94
+ })
95
+ })
96
+ await new Promise<void>((resolve, reject) => {
97
+ httpServer.once('error', reject)
98
+ httpServer.listen(0, '127.0.0.1', resolve)
99
+ })
100
+ const port = (httpServer.address() as AddressInfo).port
101
+ console.log(` [server] listening on 127.0.0.1:${port}`)
102
+
103
+ // ============ клиент ============
104
+ const hub = createRpcClientHub(
105
+ () => io(`http://127.0.0.1:${port}`, {transports: ['websocket'], forceNew: true}),
106
+ r => ({api: r<any>('conflate-store')}) as const,
107
+ )
108
+ const clients = await hub.setToken(null)
109
+ await clients.api.readyStrict()
110
+ const deep = clients.api.func as any
111
+ let hubB: any = null // второй клиент (быстрый сосед медленного)
112
+ let hub2: any = null // реконнект первого клиента
113
+
114
+ try {
115
+ let envelopes = 0
116
+ const remote: ReplayRemote<[StorePatch]> = {
117
+ line: {on: (cb: any) => deep.replay.line.on((ev: any) => { envelopes++; cb(ev) })},
118
+ since: (s: number) => deep.replay.since(s),
119
+ keyframe: () => deep.replay.keyframe(),
120
+ }
121
+ const mirror = createStore<World>({units: {}, tick: -1})
122
+ const seqs: number[] = []
123
+ const sub = syncStoreReplay(mirror, remote, {onSeq: s => seqs.push(s)})
124
+ await sub.ready
125
+ ok(json(mirror.state) == json(backend.snapshot()), 'fresh client converged via keyframe over the wire')
126
+
127
+ backend.state.tick = 1
128
+ await flushReactive(backend.state)
129
+ await waitFor('live patch over wire', () => mirror.state.tick == 1)
130
+ ok(json(mirror.state) == json(backend.snapshot()), 'live patches flow through the transparent gate')
131
+
132
+ // ============ буфер клиента «переполнен»: дельты в провод не уходят ============
133
+ await deep.debug.setPending(100)
134
+ for (let i = 2; i <= 12; i++) {
135
+ backend.state.units['alpha'].hp = 100 - i
136
+ backend.state.tick = i
137
+ await flushReactive(backend.state)
138
+ }
139
+ await delay(150)
140
+ ok(mirror.state.tick == 1, 'stalled client received NO deltas over the wire')
141
+ const stats1: ConflateStats = await deep.debug.stats()
142
+ ok(stats1.conflating && stats1.dropped >= 11, `deltas were dropped server-side, not queued (dropped=${stats1.dropped})`)
143
+ const envBefore = envelopes
144
+
145
+ // ============ буфер слился → ОДИН keyframe вместо 22 патчей ============
146
+ await deep.debug.setPending(0)
147
+ await waitFor('recovery over wire', () => mirror.state.tick == 12)
148
+ ok(json(mirror.state) == json(backend.snapshot()), 'recovered mirror equals backend')
149
+ ok(envelopes - envBefore == 1, `stall collapsed into 1 envelope on the wire (got ${envelopes - envBefore})`)
150
+ const stats2: ConflateStats = await deep.debug.stats()
151
+ ok(stats2.keyframes == 1 && !stats2.conflating, 'recovery cost exactly one keyframe')
152
+
153
+ // ============ live после восстановления ============
154
+ backend.state.tick = 13
155
+ await flushReactive(backend.state)
156
+ await waitFor('live after recovery', () => mirror.state.tick == 13)
157
+ ok(json(mirror.state) == json(backend.snapshot()), 'live stream continues after recovery')
158
+
159
+ // ============ keyOf-ворота: затор схлопывается в хвост последних значений, НЕ в keyframe ============
160
+ let envelopesK = 0
161
+ const remoteK: ReplayRemote<[StorePatch]> = {
162
+ line: {on: (cb: any) => deep.replayK.line.on((ev: any) => { envelopesK++; cb(ev) })},
163
+ since: (s: number) => deep.replayK.since(s),
164
+ keyframe: () => deep.replayK.keyframe(),
165
+ }
166
+ const mirrorK = createStore<World>({units: {}, tick: -1})
167
+ const subK = syncStoreReplay(mirrorK, remoteK)
168
+ await subK.ready
169
+ ok(json(mirrorK.state) == json(backend.snapshot()), 'keyOf line: fresh mirror converged via keyframe')
170
+
171
+ await deep.debug.setPendingK(100)
172
+ for (let i = 14; i <= 23; i++) {
173
+ backend.state.units['alpha'].hp = 200 - i
174
+ backend.state.tick = i
175
+ await flushReactive(backend.state)
176
+ }
177
+ await delay(150)
178
+ ok(mirrorK.state.tick == 13, 'stalled keyOf client received NO deltas over the wire')
179
+ const envKBefore = envelopesK
180
+ await deep.debug.setPendingK(0)
181
+ await waitFor('coalesced recovery over wire', () => mirrorK.state.tick == 23)
182
+ ok(json(mirrorK.state) == json(backend.snapshot()), 'keyOf mirror equals backend after recovery')
183
+ ok(envelopesK - envKBefore == 2, `20 missed patches collapsed into 2 tail envelopes (got ${envelopesK - envKBefore})`)
184
+ const statsK1: ConflateStats = await deep.debug.statsK()
185
+ ok(statsK1.flushes == 1 && statsK1.keyframes == 0, 'recovery was a coalesced tail — the big store never travelled')
186
+
187
+ // ============ maxKeys: пять путей в заторе → деградация до keyframe, память ограничена ключами ============
188
+ await deep.debug.setPendingK(100)
189
+ backend.state.units['alpha'].hp = 1
190
+ backend.state.units['alpha'].x = 2
191
+ backend.state.units['beta'].hp = 3
192
+ backend.state.units['beta'].x = 4
193
+ backend.state.units['gamma'].hp = 5
194
+ await flushReactive(backend.state)
195
+ await deep.debug.setPendingK(0)
196
+ await waitFor('keyframe degradation over wire', () => mirrorK.state.units['gamma']?.hp == 5)
197
+ ok(json(mirrorK.state) == json(backend.snapshot()), 'over-maxKeys episode converged via keyframe')
198
+ const statsK2: ConflateStats = await deep.debug.statsK()
199
+ ok(statsK2.keyframes == 1 && statsK2.flushes == 1, 'over maxKeys the episode degraded to exactly one keyframe')
200
+ subK()
201
+
202
+ // ============ два клиента на одной линии: медленный конфлюирует, быстрый стримит ============
203
+ hubB = createRpcClientHub(
204
+ () => io(`http://127.0.0.1:${port}`, {transports: ['websocket'], forceNew: true}),
205
+ r => ({api: r<any>('conflate-store')}) as const,
206
+ )
207
+ const clientsB = await hubB.setToken(null)
208
+ await clientsB.api.readyStrict()
209
+ const deepB = clientsB.api.func as any
210
+ const remoteB: ReplayRemote<[StorePatch]> = {
211
+ line: {on: (cb: any) => deepB.replay.line.on(cb)},
212
+ since: (s: number) => deepB.replay.since(s),
213
+ keyframe: () => deepB.replay.keyframe(),
214
+ }
215
+ const mirrorB = createStore<World>({units: {}, tick: -1})
216
+ const subB = syncStoreReplay(mirrorB, remoteB)
217
+ await subB.ready
218
+ ok(json(mirrorB.state) == json(backend.snapshot()), 'second client converged via its own keyframe')
219
+
220
+ await deep.debug.setPending(100)
221
+ const tickBeforeStall = mirror.state.tick
222
+ for (let i = 30; i <= 35; i++) {
223
+ backend.state.tick = i
224
+ await flushReactive(backend.state)
225
+ }
226
+ await waitFor('fast client streams live', () => mirrorB.state.tick == 35)
227
+ ok(mirror.state.tick == tickBeforeStall, 'slow client got NOTHING while its neighbour streamed live')
228
+ const statsB: ConflateStats = await deepB.debug.stats()
229
+ ok(!statsB.conflating && statsB.dropped == 0 && statsB.keyframes == 0, 'fast client gate stayed fully transparent')
230
+
231
+ const envABefore = envelopes
232
+ await deep.debug.setPending(0)
233
+ await waitFor('slow client recovery', () => mirror.state.tick == 35)
234
+ ok(json(mirror.state) == json(backend.snapshot()), 'slow client converged after drain')
235
+ ok(envelopes - envABefore == 1, `slow client stall collapsed into 1 envelope (got ${envelopes - envABefore})`)
236
+ ok(json(mirrorB.state) == json(backend.snapshot()), 'fast client unaffected by neighbour recovery')
237
+ subB()
238
+
239
+ // ============ реконнект ПОСРЕДИ conflation-эпизода: журнал полон, since добирает дропнутое ============
240
+ await deep.debug.setPending(100)
241
+ for (let i = 36; i <= 42; i++) {
242
+ backend.state.units['alpha'].hp = i
243
+ backend.state.tick = i
244
+ await flushReactive(backend.state)
245
+ }
246
+ await delay(100)
247
+ ok(mirror.state.tick == 35, 'client is mid-episode: deltas dropped server-side')
248
+ const statsMid: ConflateStats = await deep.debug.stats()
249
+ ok(statsMid.conflating, 'gate is still conflating at disconnect time')
250
+ const at = sub.seq()
251
+ sub()
252
+ hub.socket?.disconnect?.()
253
+
254
+ hub2 = createRpcClientHub(
255
+ () => io(`http://127.0.0.1:${port}`, {transports: ['websocket'], forceNew: true}),
256
+ r => ({api: r<any>('conflate-store')}) as const,
257
+ )
258
+ const clients2 = await hub2.setToken(null)
259
+ await clients2.api.readyStrict()
260
+ const deep2 = clients2.api.func as any
261
+ const seqs2: number[] = []
262
+ const remote2: ReplayRemote<[StorePatch]> = {
263
+ line: {on: (cb: any) => deep2.replay.line.on(cb)},
264
+ since: (s: number) => deep2.replay.since(s),
265
+ keyframe: () => deep2.replay.keyframe(),
266
+ }
267
+ const sub2 = syncStoreReplay(mirror, remote2, {since: at, onSeq: s => seqs2.push(s)})
268
+ await sub2.ready
269
+ ok(json(mirror.state) == json(backend.snapshot()), 'reconnect mid-episode converged from the journal tail')
270
+ ok(seqs2.length == 14, `gate drops did not hole the journal: since delivered the 14 patches, not a keyframe (got ${seqs2.length})`)
271
+ ok(seqs2.every((s, i) => i == 0 || s > seqs2[i - 1]), `reconnect seqs strictly ascending: ${seqs2.join(',')}`)
272
+ sub2()
273
+
274
+ ok(seqs.every((s, i) => i == 0 || s > seqs[i - 1]), `delivered seqs strictly ascending: ${seqs.join(',')}`)
275
+ console.log(fails ? `\n${fails} FAILED` : '\nall passed')
276
+ } finally {
277
+ hub.socket?.disconnect?.()
278
+ hubB?.socket?.disconnect?.()
279
+ hub2?.socket?.disconnect?.()
280
+ await delay(20)
281
+ exposed.close()
282
+ ioServer.close()
283
+ await new Promise<void>(resolve => httpServer.close(() => resolve()))
284
+ }
285
+ if (fails) process.exit(1)
286
+ }
287
+
288
+ main().catch(e => { console.error(e); process.exit(1) })
@@ -0,0 +1,225 @@
1
+ // ============================================================
2
+ // replay/conflate.test.ts
3
+ //
4
+ // Слой D.1: per-client conflation + snapshot recovery.
5
+ // Часть 1 — универсальная линия (счётчик), часть 2 — независимость
6
+ // per-client, часть 3 — зеркало стора через async-провод с лагом.
7
+ // Запуск:
8
+ // npx ts-node replay/conflate.test.ts
9
+ // ============================================================
10
+
11
+ import {createStore} from '../src/Common/Observe/store'
12
+ import {flushReactive} from '../src/Common/Observe/reactive'
13
+ import {replayListen, replaySubscribe, ReplayRemote} from '../src/Common/events/replay-index'
14
+ import {exposeStoreReplay, syncStoreReplay} from '../src/Common/Observe/store-replay'
15
+ import {conflateReplay} from '../src/Common/events/replay-index'
16
+
17
+ let fails = 0
18
+ const ok = (condition: any, message: string) => {
19
+ if (!condition) { fails++; console.log(' FAIL', message) }
20
+ else console.log(' OK ', message)
21
+ }
22
+ const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
23
+ const json = (v: any) => JSON.stringify(v)
24
+ const ascendingUnique = (seqs: number[]) => seqs.every((s, i) => i == 0 || s > seqs[i - 1])
25
+
26
+ async function waitFor(label: string, cond: () => boolean) {
27
+ for (let i = 0; i < 120; i++) {
28
+ if (cond()) return
29
+ await delay(10)
30
+ }
31
+ throw new Error(`timeout: ${label}`)
32
+ }
33
+
34
+ type World = {
35
+ units: Record<string, {hp: number, x: number}>
36
+ tick: number
37
+ }
38
+
39
+ async function main() {
40
+ console.log('\n[conflate] generic line: drop over highWater, keyframe recovery on drain')
41
+ {
42
+ let value = 0
43
+ const [emit, replay] = replayListen<[number]>({current: () => [value], history: 100})
44
+ const push = (n: number) => { value = n; emit(n) }
45
+ const buf = {v: 0} // симулированный исходящий буфер клиента
46
+ const gated = conflateReplay(replay, {pending: () => buf.v, highWater: 5, pollMs: 10})
47
+ const remote: ReplayRemote<[number]> = {
48
+ line: gated.api.line,
49
+ since: s => gated.api.since(s),
50
+ keyframe: () => gated.api.keyframe(),
51
+ }
52
+ const got: number[] = []
53
+ const seqs: number[] = []
54
+ const sub = replaySubscribe(remote, (n: number) => got.push(n), {onSeq: s => seqs.push(s)})
55
+ await sub.ready
56
+ ok(json(got) == json([0]), 'fresh subscriber got the keyframe')
57
+
58
+ push(1)
59
+ push(2)
60
+ ok(json(got) == json([0, 1, 2]), 'below highWater deltas pass through untouched')
61
+ ok(gated.stats().dropped == 0 && gated.stats().keyframes == 0, 'gate was transparent so far')
62
+
63
+ // ============ переполнение: дельты перестают отправляться ============
64
+ buf.v = 10
65
+ for (let n = 3; n <= 7; n++) push(n)
66
+ ok(got[got.length - 1] == 2, 'over highWater: deltas stop reaching the client')
67
+ ok(gated.stats().conflating && gated.stats().dropped == 5, `all 5 deltas dropped, not queued (dropped=${gated.stats().dropped})`)
68
+
69
+ // ============ буфер слился в тишине → восстановление по опросу ============
70
+ buf.v = 0
71
+ await waitFor('poll recovery', () => got[got.length - 1] == 7)
72
+ ok(got[got.length - 1] == 7 && !got.includes(3), '5 missed deltas collapsed into ONE fresh keyframe')
73
+ ok(gated.stats().keyframes == 1 && !gated.stats().conflating, 'exactly one recovery keyframe')
74
+
75
+ push(8)
76
+ ok(got[got.length - 1] == 8, 'live deltas resume after recovery')
77
+
78
+ // ============ восстановление ближайшим же событием, без ожидания опроса ============
79
+ buf.v = 10
80
+ push(9)
81
+ ok(got[got.length - 1] == 8, 'delta while conflating is dropped')
82
+ buf.v = 0
83
+ push(10)
84
+ ok(got[got.length - 1] == 10, 'drained buffer + next event → keyframe immediately, no poll wait')
85
+ ok(gated.stats().keyframes == 2, 'recovery was a keyframe, not the delta')
86
+ ok(got.filter(n => n == 10).length == 1, 'keyframe covering the delta is not duplicated by it')
87
+
88
+ ok(ascendingUnique(seqs), `delivered seqs strictly ascending: ${seqs.join(',')}`)
89
+
90
+ // ============ close: ворота мертвы навсегда ============
91
+ const before = got.length
92
+ gated.close()
93
+ push(11)
94
+ ok(got.length == before, 'after close() nothing reaches the client')
95
+ sub()
96
+ }
97
+
98
+ console.log('\n[conflate] lowWater hysteresis: recovery waits for a REAL drain, not the first dip')
99
+ {
100
+ let value = 0
101
+ const [emit, replay] = replayListen<[number]>({current: () => [value], history: 100})
102
+ const push = (n: number) => { value = n; emit(n) }
103
+ const buf = {v: 0}
104
+ const gated = conflateReplay(replay, {pending: () => buf.v, highWater: 5, lowWater: 2, pollMs: 10})
105
+ const got: number[] = []
106
+ const sub = replaySubscribe<[number]>(
107
+ {line: gated.api.line, since: s => gated.api.since(s), keyframe: () => gated.api.keyframe()},
108
+ n => got.push(n))
109
+ await sub.ready
110
+
111
+ buf.v = 10
112
+ push(1)
113
+ ok(gated.stats().conflating, 'over highWater → conflating')
114
+
115
+ // ============ серая зона (lowWater < pending <= highWater): ворота НЕ открываются ============
116
+ buf.v = 4
117
+ push(2) // событийный путь восстановления не должен сработать
118
+ await delay(50) // и опрос тоже
119
+ ok(got.length == 1 && gated.stats().keyframes == 0, 'between lowWater and highWater the gate stays shut (hysteresis)')
120
+ ok(gated.stats().dropped == 2, 'deltas in the gray zone are still dropped, not queued')
121
+
122
+ // ============ настоящий слив: pending <= lowWater ============
123
+ buf.v = 2
124
+ await waitFor('hysteresis recovery', () => got[got.length - 1] == 2)
125
+ ok(gated.stats().keyframes == 1 && !gated.stats().conflating, 'recovery fired only at lowWater')
126
+
127
+ // обратный вход в conflation — только НАД highWater
128
+ buf.v = 4
129
+ push(3)
130
+ ok(got[got.length - 1] == 3 && !gated.stats().conflating, 'below highWater the gate does not re-enter conflation')
131
+ sub(); gated.close()
132
+ }
133
+
134
+ console.log('\n[conflate] misuse: no current provider → loud construction error')
135
+ {
136
+ const [, bare] = replayListen<[number]>({history: 10})
137
+ let threw = false
138
+ try { conflateReplay(bare, {pending: () => 0, highWater: 1}) } catch { threw = true }
139
+ ok(threw, 'conflateReplay without keyframe recovery throws at construction')
140
+ }
141
+
142
+ console.log('\n[conflate] per-client independence: slow client conflates, fast one streams')
143
+ {
144
+ let value = 0
145
+ const [emit, replay] = replayListen<[number]>({current: () => [value], history: 100})
146
+ const push = (n: number) => { value = n; emit(n) }
147
+ const bufSlow = {v: 100}
148
+ const fast = conflateReplay(replay, {pending: () => 0, highWater: 5, pollMs: 10})
149
+ const slow = conflateReplay(replay, {pending: () => bufSlow.v, highWater: 5, pollMs: 10})
150
+ const gotFast: number[] = []
151
+ const gotSlow: number[] = []
152
+ const subFast = replaySubscribe<[number]>(
153
+ {line: fast.api.line, since: s => fast.api.since(s), keyframe: () => fast.api.keyframe()},
154
+ n => gotFast.push(n))
155
+ const subSlow = replaySubscribe<[number]>(
156
+ {line: slow.api.line, since: s => slow.api.since(s), keyframe: () => slow.api.keyframe()},
157
+ n => gotSlow.push(n))
158
+ await subFast.ready
159
+ await subSlow.ready
160
+
161
+ for (let n = 1; n <= 6; n++) push(n)
162
+ ok(gotFast[gotFast.length - 1] == 6 && gotFast.length == 7, 'fast client received every delta')
163
+ ok(gotSlow.length == 1, 'slow client got nothing extra — its line is conflated')
164
+
165
+ bufSlow.v = 0
166
+ await waitFor('slow client recovery', () => gotSlow[gotSlow.length - 1] == 6)
167
+ ok(gotSlow.length == 2, 'slow client caught up with ONE keyframe, no backlog')
168
+ subFast(); subSlow(); fast.close(); slow.close()
169
+ }
170
+
171
+ console.log('\n[conflate] store mirror over a lagged async wire')
172
+ {
173
+ const backend = createStore<World>({units: {a: {hp: 100, x: 0}}, tick: 0}, {drain: 'micro'})
174
+ const exposed = exposeStoreReplay(backend, {history: 100})
175
+ const buf = {v: 0}
176
+ const gated = conflateReplay(exposed.replay, {pending: () => buf.v, highWater: 3, pollMs: 10})
177
+ let envelopes = 0
178
+ const remote: ReplayRemote<any> = {
179
+ line: {on: (cb: any) => gated.api.line.on((ev: any) => { envelopes++; cb(ev) })},
180
+ since: async s => { await delay(10); return gated.api.since(s) },
181
+ keyframe: async () => { await delay(10); return gated.api.keyframe() },
182
+ }
183
+ const mirror = createStore<World>({units: {}, tick: -1})
184
+ const seqs: number[] = []
185
+ const sub = syncStoreReplay(mirror, remote, {onSeq: s => seqs.push(s)})
186
+ await sub.ready
187
+ ok(json(mirror.state) == json(backend.snapshot()), 'fresh mirror converged via keyframe')
188
+
189
+ backend.state.tick = 1
190
+ await flushReactive(backend.state)
191
+ ok(json(mirror.state) == json(backend.snapshot()), 'live patches flow through the transparent gate')
192
+
193
+ // ============ клиент «завис»: патчи копятся только у бэкенда ============
194
+ buf.v = 50
195
+ for (let i = 2; i <= 12; i++) {
196
+ backend.state.units['a'].hp = i
197
+ backend.state.tick = i
198
+ await flushReactive(backend.state)
199
+ }
200
+ ok(mirror.state.tick == 1, 'stalled client got NO deltas (no queue growing for it)')
201
+ const envBefore = envelopes
202
+
203
+ // ============ слился → один keyframe вместо 22 патчей ============
204
+ buf.v = 0
205
+ await waitFor('mirror recovery', () => mirror.state.tick == 12)
206
+ ok(json(mirror.state) == json(backend.snapshot()), 'recovered mirror equals backend')
207
+ ok(envelopes - envBefore == 1, `22 missed patches collapsed into 1 envelope (got ${envelopes - envBefore})`)
208
+ ok(gated.stats().keyframes == 1, 'recovery cost exactly one keyframe')
209
+
210
+ backend.state.tick = 13
211
+ await flushReactive(backend.state)
212
+ await waitFor('live after recovery', () => mirror.state.tick == 13)
213
+ ok(json(mirror.state) == json(backend.snapshot()), 'live stream continues after recovery')
214
+ ok(ascendingUnique(seqs), `delivered seqs strictly ascending: ${seqs.join(',')}`)
215
+
216
+ sub()
217
+ gated.close()
218
+ exposed.close()
219
+ }
220
+
221
+ console.log(fails ? `\n${fails} FAILED` : '\nall passed')
222
+ if (fails) process.exit(1)
223
+ }
224
+
225
+ main().catch(e => { console.error(e); process.exit(1) })