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,199 @@
1
+ // ============================================================
2
+ // replay/socket-replay.test.ts
3
+ //
4
+ // Слой B через НАСТОЯЩИЙ Socket.IO localhost WebSocket:
5
+ // зеркало стора по replay-линии (keyframe + seq-дельты).
6
+ // Запуск:
7
+ // npx ts-node replay/socket-replay.test.ts
8
+ // ============================================================
9
+
10
+ import express from 'express'
11
+ import {createServer} from 'http'
12
+ import type {AddressInfo} from 'net'
13
+ import {Server as SocketIOServer} from 'socket.io'
14
+ import {io} from 'socket.io-client'
15
+ import {listen as createListenPair} from '../src/Common/events/Listen'
16
+ import {createRpcClientHub} from '../src/Common/rcp/rpc-clientHub'
17
+ import {createRpcServerAuto} from '../src/Common/rcp/rpc-server-auto'
18
+ import {createStore, StorePatch} from '../src/Common/Observe/store'
19
+ import {flushReactive} from '../src/Common/Observe/reactive'
20
+ import {exposeStoreReplay, syncStoreReplay} from '../src/Common/Observe/store-replay'
21
+ import {ReplayRemote} from '../src/Common/events/replay-index'
22
+
23
+ let fails = 0
24
+ const ok = (condition: any, message: string) => {
25
+ if (!condition) { fails++; console.log(' FAIL', message) }
26
+ else console.log(' OK ', message)
27
+ }
28
+ const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
29
+ const json = (v: any) => JSON.stringify(v)
30
+
31
+ async function waitFor(label: string, cond: () => boolean) {
32
+ for (let i = 0; i < 120; i++) {
33
+ if (cond()) return
34
+ await delay(25)
35
+ }
36
+ throw new Error(`timeout: ${label}`)
37
+ }
38
+
39
+ type World = {
40
+ units: Record<string, {hp: number, x: number}>
41
+ tick: number
42
+ }
43
+
44
+ async function startRealServer(object: object) {
45
+ const app = express()
46
+ const httpServer = createServer(app)
47
+ const ioServer = new SocketIOServer(httpServer, {maxHttpBufferSize: 1e8})
48
+ ioServer.on('connection', socket => {
49
+ const [disconnect, disconnectListen] = createListenPair<[]>()
50
+ socket.on('disconnect', () => disconnect())
51
+ createRpcServerAuto({
52
+ socket: {
53
+ emit: (key, data) => socket.emit(key, data),
54
+ on: (key, cb) => socket.on(key, cb),
55
+ },
56
+ socketKey: 'replay-store',
57
+ object,
58
+ disconnectListen,
59
+ })
60
+ })
61
+ await new Promise<void>((resolve, reject) => {
62
+ httpServer.once('error', reject)
63
+ httpServer.listen(0, '127.0.0.1', resolve)
64
+ })
65
+ const port = (httpServer.address() as AddressInfo).port
66
+ console.log(` [server] listening on 127.0.0.1:${port}`)
67
+ return {
68
+ port,
69
+ close: () => new Promise<void>(resolve => {
70
+ ioServer.close()
71
+ httpServer.close(() => resolve())
72
+ }),
73
+ }
74
+ }
75
+
76
+ async function startRealClient<T extends object>(port: number) {
77
+ const hub = createRpcClientHub(
78
+ () => io(`http://127.0.0.1:${port}`, {transports: ['websocket'], forceNew: true}),
79
+ r => ({api: r<T>('replay-store')}) as const,
80
+ )
81
+ const clients = await hub.setToken(null)
82
+ await clients.api.readyStrict()
83
+ return {
84
+ client: clients.api,
85
+ close: () => hub.socket?.disconnect?.(),
86
+ }
87
+ }
88
+
89
+ async function main() {
90
+ console.log('\n[socket-replay] store mirror over a real Socket.IO wire')
91
+
92
+ const backend = createStore<World>({units: {alpha: {hp: 100, x: 0}}, tick: 0}, {drain: 'micro'})
93
+ const exposed = exposeStoreReplay(backend, {history: 6})
94
+
95
+ // счётчики серверной цены: pull-ов быть не должно, keyframe — только по fallback
96
+ const counters = {get: 0, since: 0, keyframe: 0}
97
+ const facade = {
98
+ ...exposed.api,
99
+ get: (mask?: any) => { counters.get++; return exposed.api.get(mask) },
100
+ replay: {
101
+ ...exposed.api.replay,
102
+ since: (s: number) => { counters.since++; return exposed.api.replay.since(s) },
103
+ keyframe: () => { counters.keyframe++; return exposed.api.replay.keyframe() },
104
+ },
105
+ }
106
+
107
+ type Client = Awaited<ReturnType<typeof startRealClient<typeof facade>>>
108
+ const server = await startRealServer(facade)
109
+ let c1: Client | null = null
110
+ let c2: Client | null = null
111
+
112
+ try {
113
+ // ============ свежий клиент: keyframe + live ============
114
+ c1 = await startRealClient<typeof facade>(server.port)
115
+ const deep1 = c1.client.func as any
116
+ const remote1: ReplayRemote<[StorePatch]> = {
117
+ line: deep1.replay.line,
118
+ since: (s: number) => deep1.replay.since(s),
119
+ keyframe: () => deep1.replay.keyframe(),
120
+ }
121
+ const mirror = createStore<World>({units: {}, tick: -1})
122
+ let lastSeq = -1
123
+ const sub = syncStoreReplay(mirror, remote1, {onSeq: s => lastSeq = s})
124
+ await sub.ready
125
+ ok(json(mirror.state) == json(backend.snapshot()), 'fresh client converged via keyframe over the wire')
126
+ ok(counters.keyframe == 1 && counters.since == 0, 'exactly one keyframe request, no tail request')
127
+
128
+ backend.state.units['beta'] = {hp: 50, x: 5}
129
+ backend.state.tick = 1
130
+ await flushReactive(backend.state)
131
+ await waitFor('live patches over wire', () => mirror.state.tick == 1 && 'beta' in mirror.state.units)
132
+ ok(json(mirror.state) == json(backend.snapshot()), 'live patches keep the wire mirror converged')
133
+ ok(counters.get == 0, 'pure push: server get() was never pulled')
134
+
135
+ // ============ обрыв связи, изменения в оффлайне ============
136
+ sub()
137
+ c1.close()
138
+ c1 = null
139
+ await delay(30)
140
+
141
+ backend.state.units['alpha'].hp = 75
142
+ await flushReactive(backend.state)
143
+ backend.state.tick = 2
144
+ await flushReactive(backend.state)
145
+
146
+ // ============ реконнект с since → только хвост ============
147
+ const kfBefore = counters.keyframe
148
+ c2 = await startRealClient<typeof facade>(server.port)
149
+ const deep2 = c2.client.func as any
150
+ const remote2: ReplayRemote<[StorePatch]> = {
151
+ line: deep2.replay.line,
152
+ since: (s: number) => deep2.replay.since(s),
153
+ keyframe: () => deep2.replay.keyframe(),
154
+ }
155
+ let tailPatches = 0
156
+ const sub2 = syncStoreReplay(mirror, remote2, {
157
+ since: lastSeq,
158
+ onSeq: s => { lastSeq = s; tailPatches++ },
159
+ })
160
+ await sub2.ready
161
+ ok(json(mirror.state) == json(backend.snapshot()), 'reconnect with since converged over the wire')
162
+ ok(counters.keyframe == kfBefore, 'reconnect did NOT cost a snapshot — tail of patches sufficed')
163
+ ok(tailPatches == 2, `tail was exactly the missed patches (got ${tailPatches})`)
164
+
165
+ // ============ долгий оффлайн → журнал (history: 6) вытеснен → keyframe ============
166
+ sub2()
167
+ for (let i = 3; i <= 30; i++) {
168
+ backend.state.tick = i
169
+ await flushReactive(backend.state)
170
+ }
171
+ let resyncDeliveries = 0
172
+ const sub3 = syncStoreReplay(mirror, remote2, {
173
+ since: lastSeq,
174
+ onSeq: s => { lastSeq = s; resyncDeliveries++ },
175
+ })
176
+ await sub3.ready
177
+ ok(json(mirror.state) == json(backend.snapshot()), 'evicted journal → fresh keyframe converged')
178
+ ok(counters.keyframe == kfBefore + 1, 'fallback took exactly one fresh keyframe')
179
+ ok(resyncDeliveries == 1, `no backlog: 28 missed patches collapsed into 1 keyframe (got ${resyncDeliveries})`)
180
+
181
+ // live после fallback продолжает идти
182
+ backend.state.tick = 31
183
+ await flushReactive(backend.state)
184
+ await waitFor('live after fallback', () => mirror.state.tick == 31)
185
+ ok(true, 'live stream continues after keyframe recovery')
186
+ sub3()
187
+
188
+ console.log(fails ? `\n${fails} FAILED` : '\nall passed')
189
+ } finally {
190
+ c1?.close()
191
+ c2?.close()
192
+ await delay(20)
193
+ exposed.close()
194
+ await server.close()
195
+ }
196
+ if (fails) process.exit(1)
197
+ }
198
+
199
+ main().catch(e => { console.error(e); process.exit(1) })
@@ -0,0 +1,176 @@
1
+ // ============================================================
2
+ // replay/staleness.test.ts
3
+ //
4
+ // Вотчдог тухлости: продьюсер (withReplayListen {staleMs, onStale},
5
+ // isStale()/lastTs()) и клиент (replaySubscribe {staleMs, onStale,
6
+ // skewMs}, off.isStale()/off.lastTs()). Сценарии: молчащая линия
7
+ // (edge в обе стороны, не будильник); холодная линия без таймера;
8
+ // ленивые геттеры без onStale; тухлый keyframe — stale сразу по
9
+ // доставке; НЕТ флапа на историческом хвосте since-catch-up;
10
+ // допуск skewMs; снятие таймеров off()/close().
11
+ // Запуск:
12
+ // npx ts-node replay/staleness.test.ts
13
+ // ============================================================
14
+
15
+ import {replayListen, exposeReplay, replaySubscribe, StaleInfo, ReplayRemote} 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 edgesOf = (list: StaleInfo[]) => list.map(i => i.stale ? 'T' : 'F').join(',')
24
+
25
+ async function main() {
26
+ console.log('\n[staleness] producer: silent line, edge-triggered both directions')
27
+ {
28
+ const edges: StaleInfo[] = []
29
+ const [emit, listen] = replayListen<[number]>({history: 8, staleMs: 40, onStale: i => edges.push(i)})
30
+ emit(1)
31
+ ok(!listen.isStale() && listen.lastTs() > 0, 'fresh right after emit')
32
+ await delay(100)
33
+ ok(edgesOf(edges) == 'T', `fresh->stale fired exactly once, not a repeating alarm (${edgesOf(edges)})`)
34
+ ok(listen.isStale(), 'isStale() agrees while silent')
35
+ ok(edges[0].age >= 40 && edges[0].lastTs == listen.lastTs(), 'edge info carries lastTs/age')
36
+ emit(2)
37
+ ok(edgesOf(edges) == 'T,F', 'stale->fresh fired on the reviving emit')
38
+ ok(!listen.isStale(), 'fresh again')
39
+ await delay(100)
40
+ ok(edgesOf(edges) == 'T,F,T', 'goes stale again after the next silence')
41
+ listen.close()
42
+ }
43
+
44
+ console.log('\n[staleness] producer: cold line stays free')
45
+ {
46
+ const edges: StaleInfo[] = []
47
+ const [, listen] = replayListen<[number]>({history: 8, staleMs: 30, onStale: i => edges.push(i)})
48
+ await delay(90)
49
+ ok(edges.length == 0, 'no events ever -> watchdog never armed, no edges')
50
+ ok(!listen.isStale() && listen.lastTs() == 0, 'cold line is not stale (freshness unknown, not expired)')
51
+ listen.close()
52
+ }
53
+
54
+ console.log('\n[staleness] producer: lazy getters without onStale (no timer needed)')
55
+ {
56
+ const [emit, listen] = replayListen<[number]>({history: 8, staleMs: 30})
57
+ emit(1)
58
+ await delay(70)
59
+ ok(listen.isStale(), 'isStale() computes lazily, no onStale installed')
60
+ emit(2)
61
+ ok(!listen.isStale(), 'revives on emit')
62
+ listen.close()
63
+ }
64
+
65
+ console.log('\n[staleness] producer: close() disarms the watchdog')
66
+ {
67
+ const edges: StaleInfo[] = []
68
+ const [emit, listen] = replayListen<[number]>({history: 8, staleMs: 30, onStale: i => edges.push(i)})
69
+ emit(1)
70
+ listen.close()
71
+ await delay(90)
72
+ ok(edges.length == 0, 'no stale edge after close()')
73
+ }
74
+
75
+ console.log('\n[staleness] client: silent line over the wire shape (arrival gap)')
76
+ {
77
+ const [emit, listen] = replayListen<[number]>({history: 8})
78
+ const remote = exposeReplay(listen) as unknown as ReplayRemote<[number]>
79
+ const edges: StaleInfo[] = []
80
+ const seen: number[] = []
81
+ const sub = replaySubscribe(remote, v => seen.push(v), {staleMs: 40, onStale: i => edges.push(i)})
82
+ await sub.ready
83
+ emit(1)
84
+ ok(!sub.isStale() && sub.lastTs() == listen.lastTs(), 'fresh after delivery, lastTs mirrors the envelope')
85
+ await delay(100)
86
+ ok(edgesOf(edges) == 'T', `silent wire -> one stale edge (${edgesOf(edges)})`)
87
+ ok(sub.isStale(), 'off.isStale() agrees')
88
+ emit(2)
89
+ ok(edgesOf(edges) == 'T,F' && seen.join(',') == '1,2', 'delivery revives: fresh edge once, data intact')
90
+ await delay(100)
91
+ ok(edgesOf(edges) == 'T,F,T', 'stale again after the next silence')
92
+ sub()
93
+ emit(3)
94
+ await delay(100)
95
+ ok(edgesOf(edges) == 'T,F,T', 'off() disarms the client watchdog — no edges after unsubscribe')
96
+ listen.close()
97
+ }
98
+
99
+ console.log('\n[staleness] client: dead producer at connect (silent from birth)')
100
+ {
101
+ const [, listen] = replayListen<[number]>({history: 8})
102
+ const remote = exposeReplay(listen) as unknown as ReplayRemote<[number]>
103
+ const edges: StaleInfo[] = []
104
+ const sub = replaySubscribe(remote, () => {}, {staleMs: 40, onStale: i => edges.push(i)})
105
+ await sub.ready
106
+ await delay(100)
107
+ ok(edgesOf(edges) == 'T', 'no envelope ever -> stale from the subscribe-time arrival gap')
108
+ const lazy = replaySubscribe(remote, () => {}, {staleMs: 40})
109
+ await lazy.ready
110
+ await delay(80)
111
+ ok(lazy.isStale(), 'isStale() works lazily without onStale (no timer)')
112
+ sub(); lazy()
113
+ listen.close()
114
+ }
115
+
116
+ console.log('\n[staleness] client: stale keyframe reported IMMEDIATELY at delivery')
117
+ {
118
+ let state = 7
119
+ // часы продьюсера «остановились» 500 мс назад — keyframe приедет сейчас, но с старым ts
120
+ const producerNow = () => Date.now() - 500
121
+ const [, listen] = replayListen<[number]>({history: 8, current: () => [state], now: producerNow})
122
+ const remote = exposeReplay(listen) as unknown as ReplayRemote<[number]>
123
+ const edges: StaleInfo[] = []
124
+ const seen: number[] = []
125
+ const sub = replaySubscribe(remote, v => seen.push(v), {staleMs: 100, onStale: i => edges.push(i)})
126
+ await sub.ready
127
+ ok(seen.join(',') == '7', 'keyframe delivered')
128
+ ok(edgesOf(edges) == 'T', 'stale edge right after catch-up — no staleMs of waiting')
129
+ ok(sub.isStale() && sub.lastTs() <= Date.now() - 500 + 5, 'lastTs is the old producer ts')
130
+ sub()
131
+ listen.close()
132
+ }
133
+
134
+ console.log('\n[staleness] client: historical since-tail does NOT flap')
135
+ {
136
+ let t = Date.now() - 300
137
+ const [emit, listen] = replayListen<[number]>({history: 16, now: () => t})
138
+ emit(1); emit(2); emit(3) // старый хвост (ts на 300 мс в прошлом)
139
+ t = Date.now()
140
+ emit(4); emit(5) // голова свежая
141
+ const remote = exposeReplay(listen) as unknown as ReplayRemote<[number]>
142
+ const edges: StaleInfo[] = []
143
+ const seen: number[] = []
144
+ const sub = replaySubscribe(remote, v => seen.push(v), {since: 0, staleMs: 100, onStale: i => edges.push(i)})
145
+ await sub.ready
146
+ ok(seen.join(',') == '1,2,3,4,5', 'full tail replayed')
147
+ ok(edges.length == 0, 'old tail ts never flapped stale mid-catch-up; head is fresh -> no edge at all')
148
+ ok(!sub.isStale(), 'fresh after handover')
149
+ sub()
150
+ listen.close()
151
+ }
152
+
153
+ console.log('\n[staleness] client: skewMs tolerance for producer/client clock disagreement')
154
+ {
155
+ let state = 1
156
+ const skewedNow = () => Date.now() - 80 // продьюсер отстаёт на 80 мс
157
+ const [, listen] = replayListen<[number]>({history: 8, current: () => [state], now: skewedNow})
158
+ const remote = exposeReplay(listen) as unknown as ReplayRemote<[number]>
159
+ const strict: StaleInfo[] = []
160
+ const s1 = replaySubscribe(remote, () => {}, {staleMs: 50, onStale: i => strict.push(i)})
161
+ await s1.ready
162
+ ok(edgesOf(strict) == 'T', 'skewMs=0: an 80ms-old ts trips the 50ms threshold at delivery')
163
+ s1()
164
+ const tolerant: StaleInfo[] = []
165
+ const s2 = replaySubscribe(remote, () => {}, {staleMs: 50, skewMs: 100, onStale: i => tolerant.push(i)})
166
+ await s2.ready
167
+ ok(tolerant.length == 0, 'skewMs=100 absorbs the clock offset — no false stale')
168
+ s2()
169
+ listen.close()
170
+ }
171
+
172
+ console.log(fails ? `\n${fails} FAILED` : '\nall passed')
173
+ if (fails) process.exit(1)
174
+ }
175
+
176
+ main().catch(e => { console.error(e); process.exit(1) })
@@ -0,0 +1,151 @@
1
+ import {createStore} from '../src/Common/Observe/store'
2
+ import {flushReactive} from '../src/Common/Observe/reactive'
3
+ import {exposeStoreReplay, syncStoreReplay} from '../src/Common/Observe/store-replay'
4
+ import {ReplayRemote} from '../src/Common/events/replay-index'
5
+
6
+ let fails = 0
7
+ const ok = (condition: any, message: string) => {
8
+ if (!condition) { fails++; console.log(' FAIL', message) }
9
+ else console.log(' OK ', message)
10
+ }
11
+ const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
12
+ const json = (v: any) => JSON.stringify(v)
13
+
14
+ type World = {
15
+ units: Record<string, {hp: number, x: number}>
16
+ tick: number
17
+ }
18
+
19
+ // in-proc «провод»: методы становятся async с задержкой — как настоящий RPC
20
+ function makeRemote(exposed: ReturnType<typeof exposeStoreReplay<World>>, lag = 15) {
21
+ const counters = {since: 0, keyframe: 0}
22
+ const remote: ReplayRemote<any> = {
23
+ line: exposed.replay.line,
24
+ since: async (s: number) => { counters.since++; await delay(lag); return exposed.replay.getSince(s) ?? null },
25
+ keyframe: async () => { counters.keyframe++; await delay(lag); return exposed.replay.keyframe() ?? null },
26
+ }
27
+ return {remote, counters}
28
+ }
29
+
30
+ const ascendingUnique = (seqs: number[]) => seqs.every((s, i) => i == 0 || s > seqs[i - 1])
31
+
32
+ async function main() {
33
+ console.log('\n[store-replay] fresh mirror: keyframe as root patch')
34
+ {
35
+ const backend = createStore<World>({units: {a: {hp: 100, x: 0}}, tick: 0})
36
+ const exposed = exposeStoreReplay(backend, {history: 100})
37
+ const {remote, counters} = makeRemote(exposed)
38
+ const mirror = createStore<World>({units: {}, tick: -1})
39
+ const seqs: number[] = []
40
+ const sub = syncStoreReplay(mirror, remote, {onSeq: s => seqs.push(s)})
41
+ await sub.ready
42
+ ok(json(mirror.state) == json(backend.snapshot()), 'since<0 → keyframe → mirror equals backend')
43
+ ok(counters.keyframe == 1 && counters.since == 0, 'keyframe requested exactly once, no tail call')
44
+
45
+ backend.state.units['b'] = {hp: 50, x: 5}
46
+ backend.state.tick = 1
47
+ await flushReactive(backend.state)
48
+ await delay(5)
49
+ ok(json(mirror.state) == json(backend.snapshot()), 'live patches keep mirror converged')
50
+ ok(ascendingUnique(seqs), `delivered seqs strictly ascending: ${seqs.join(',')}`)
51
+ sub()
52
+ exposed.close()
53
+ }
54
+
55
+ console.log('\n[store-replay] reconnect with since → tail only, no snapshot')
56
+ {
57
+ const backend = createStore<World>({units: {a: {hp: 100, x: 0}}, tick: 0})
58
+ const exposed = exposeStoreReplay(backend, {history: 100})
59
+ const {remote} = makeRemote(exposed)
60
+ const mirror = createStore<World>({units: {}, tick: -1})
61
+ const sub = syncStoreReplay(mirror, remote)
62
+ await sub.ready
63
+ sub() // «обрыв связи»
64
+
65
+ backend.state.units['a'].hp = 75
66
+ backend.state.units['c'] = {hp: 10, x: 9}
67
+ await flushReactive(backend.state)
68
+ backend.state.tick = 2
69
+ await flushReactive(backend.state)
70
+
71
+ const {remote: remote2, counters: counters2} = makeRemote(exposed)
72
+ const sub2 = syncStoreReplay(mirror, remote2, {since: sub.seq()})
73
+ await sub2.ready
74
+ ok(json(mirror.state) == json(backend.snapshot()), 'reconnect via since converges')
75
+ ok(counters2.keyframe == 0 && counters2.since == 1, 'reconnect cost = tail of patches, NOT a snapshot')
76
+ sub2()
77
+ exposed.close()
78
+ }
79
+
80
+ console.log('\n[store-replay] long offline → journal evicted → fresh keyframe, no backlog')
81
+ {
82
+ const backend = createStore<World>({units: {}, tick: 0})
83
+ const exposed = exposeStoreReplay(backend, {history: 3})
84
+ const {remote} = makeRemote(exposed)
85
+ const mirror = createStore<World>({units: {}, tick: -1})
86
+ const sub = syncStoreReplay(mirror, remote)
87
+ await sub.ready
88
+ sub()
89
+
90
+ for (let i = 1; i <= 20; i++) {
91
+ backend.state.tick = i
92
+ await flushReactive(backend.state)
93
+ }
94
+ let applied = 0
95
+ const {remote: remote2, counters: counters2} = makeRemote(exposed)
96
+ const counting: ReplayRemote<any> = {...remote2, line: {on: (cb: any) => remote2.line.on((ev: any) => { applied++; cb(ev) })}}
97
+ const sub2 = syncStoreReplay(mirror, counting, {since: sub.seq()})
98
+ await sub2.ready
99
+ ok(json(mirror.state) == json(backend.snapshot()), 'evicted → keyframe fallback converges')
100
+ ok(counters2.keyframe == 1, 'fallback took exactly one fresh keyframe')
101
+ ok(applied == 0, 'NO backlog: 20 offline patches never traveled')
102
+ sub2()
103
+ exposed.close()
104
+ }
105
+
106
+ console.log('\n[store-replay] events racing the catch-up window (async handover)')
107
+ {
108
+ const backend = createStore<World>({units: {a: {hp: 1, x: 1}}, tick: 0})
109
+ const exposed = exposeStoreReplay(backend, {history: 100})
110
+ const {remote} = makeRemote(exposed, 30)
111
+ const mirror = createStore<World>({units: {}, tick: -1})
112
+ const seqs: number[] = []
113
+ const sub = syncStoreReplay(mirror, remote, {onSeq: s => seqs.push(s)})
114
+ // мутации ВО ВРЕМЯ ожидания keyframe: попадут и в снапшот, и в live-очередь
115
+ backend.state.tick = 1
116
+ await flushReactive(backend.state)
117
+ backend.state.units['a'].hp = 2
118
+ await flushReactive(backend.state)
119
+ await sub.ready
120
+ backend.state.tick = 3
121
+ await flushReactive(backend.state)
122
+ await delay(5)
123
+ ok(json(mirror.state) == json(backend.snapshot()), 'overlap keyframe/queue converges')
124
+ ok(ascendingUnique(seqs), `no seq delivered twice: ${seqs.join(',')}`)
125
+ sub()
126
+ exposed.close()
127
+ }
128
+
129
+ console.log('\n[store-replay] seq from another server life → keyframe resets the line down')
130
+ {
131
+ const backend = createStore<World>({units: {z: {hp: 9, x: 9}}, tick: 100})
132
+ const exposed = exposeStoreReplay(backend, {history: 100}) // свежий сервер: head мал
133
+ const {remote, counters} = makeRemote(exposed)
134
+ const mirror = createStore<World>({units: {}, tick: -1})
135
+ const sub = syncStoreReplay(mirror, remote, {since: 99999})
136
+ await sub.ready
137
+ ok(json(mirror.state) == json(backend.snapshot()), 'future seq → keyframe fallback')
138
+ ok(counters.keyframe == 1, 'keyframe taken once')
139
+ backend.state.tick = 101
140
+ await flushReactive(backend.state)
141
+ await delay(5)
142
+ ok(mirror.state.tick == 101, 'live events AFTER the reset are not muted by the stale big seq')
143
+ sub()
144
+ exposed.close()
145
+ }
146
+
147
+ console.log(fails ? `\n${fails} FAILED` : '\nall passed')
148
+ if (fails) process.exit(1)
149
+ }
150
+
151
+ main().catch(e => { console.error(e); process.exit(1) })