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.
- package/README.md +11 -1
- package/demo/client.ts +222 -0
- package/demo/index.html +58 -0
- package/demo/server.ts +93 -0
- package/doc/INTENT.md +31 -0
- package/doc/ROADMAP.md +19 -1
- package/doc/changes/1.0.70.md +4 -0
- package/doc/changes/1.0.71.md +9 -0
- package/doc/changes/1.0.72.md +6 -0
- package/doc/changes/1.0.73.md +6 -0
- package/doc/wenay-common2-rare.md +29 -8
- package/doc/wenay-common2.md +13 -2
- package/lib/Common/media/media-index.d.ts +1 -0
- package/lib/Common/media/media-index.js +1 -0
- package/lib/Common/media/media-source.d.ts +3 -0
- package/lib/Common/media/media-source.js +136 -27
- package/lib/Common/media/media-view.d.ts +42 -0
- package/lib/Common/media/media-view.js +191 -0
- package/lib/Common/peer/peer-client.d.ts +12 -5
- package/lib/Common/peer/peer-client.js +53 -3
- package/lib/Common/peer/peer-host.d.ts +8 -4
- package/lib/Common/peer/peer-host.js +2 -2
- package/lib/Common/peer/peer-relay.d.ts +10 -2
- package/lib/Common/peer/peer-relay.js +40 -18
- package/observe/PLAN.md +131 -0
- package/observe/README.md +226 -0
- package/observe/hot-write.test.ts +95 -0
- package/observe/listen-core.test.ts +66 -0
- package/observe/listen-store.test.ts +56 -0
- package/observe/listen.test.ts +92 -0
- package/observe/reactive.test.ts +270 -0
- package/observe/reactive.ts +1 -0
- package/observe/store-manager.test.ts +118 -0
- package/observe/store-mirror.example.ts +74 -0
- package/observe/store.test.ts +235 -0
- package/observe/store.ts +1 -0
- package/observe/usage-real-socket.ts +174 -0
- package/observe/usage.ts +200 -0
- package/oracle/PASSED.md +27 -0
- package/oracle/README.md +12 -0
- package/oracle/fixes-primitives.spec.ts +90 -0
- package/oracle/realsocket/_rs.ts +106 -0
- package/oracle/realsocket/auth.spec.ts +91 -0
- package/oracle/realsocket/callbacks.spec.ts +122 -0
- package/oracle/realsocket/caps.spec.ts +124 -0
- package/oracle/realsocket/core.spec.ts +49 -0
- package/oracle/realsocket/dedupe.spec.ts +142 -0
- package/oracle/realsocket/errors.spec.ts +121 -0
- package/oracle/realsocket/lifecycle.spec.ts +128 -0
- package/oracle/realsocket/limits.spec.ts +98 -0
- package/oracle/realsocket/pipe.spec.ts +101 -0
- package/oracle/realsocket/shape.spec.ts +124 -0
- package/oracle/realsocket/slimv2.spec.ts +116 -0
- package/oracle/realsocket/stress.spec.ts +132 -0
- package/oracle/regression/_clientapiall-replay.fixture.ts +57 -0
- package/oracle/regression/async-queues.spec.ts +254 -0
- package/oracle/regression/bytestream.spec.ts +152 -0
- package/oracle/regression/clientapiall-replay-types.spec.ts +47 -0
- package/oracle/regression/core-clone-equal.spec.ts +124 -0
- package/oracle/regression/data-structures.spec.ts +135 -0
- package/oracle/regression/listen-events.spec.ts +206 -0
- package/oracle/regression/observe-core.spec.ts +278 -0
- package/oracle/regression/package-export.spec.ts +120 -0
- package/oracle/regression/rpc-dedupe-callbacks.spec.ts +195 -0
- package/oracle/regression/rpc-lifecycle.spec.ts +150 -0
- package/oracle/regression/store-each.spec.ts +209 -0
- package/package.json +6 -1
- package/replay/PLAN.md +171 -0
- package/replay/canvas-socket.test.ts +187 -0
- package/replay/coalesce.test.ts +260 -0
- package/replay/conflate-socket.test.ts +288 -0
- package/replay/conflate.test.ts +225 -0
- package/replay/history.test.ts +222 -0
- package/replay/media-socket.test.ts +157 -0
- package/replay/media-view.test.ts +164 -0
- package/replay/offline-store-socket.test.ts +290 -0
- package/replay/offline-store.test.ts +156 -0
- package/replay/peer-repair.test.ts +184 -0
- package/replay/peer-sdk.test.ts +228 -0
- package/replay/replay-listen.test.ts +156 -0
- package/replay/route-coordinator.test.ts +314 -0
- package/replay/route-handoff.test.ts +150 -0
- package/replay/route-webrtc.test.ts +321 -0
- package/replay/rpc-auto.test.ts +256 -0
- package/replay/socket-replay.test.ts +199 -0
- package/replay/staleness.test.ts +176 -0
- package/replay/store-replay.test.ts +151 -0
- package/replay/video-socket.demo.ts +200 -0
- package/doc/changes/1.0.63.md +0 -8
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// replay/canvas-socket.test.ts
|
|
3
|
+
//
|
|
4
|
+
// «Канвас-стрим»: настоящие БАЙТЫ (Uint8Array RGBA) кадрами через
|
|
5
|
+
// replay-линию по НАСТОЯЩЕМУ Socket.IO. Проверяет binary passthrough
|
|
6
|
+
// в rpc-walk: байты едут бинарём (socket.io native), а не {0:…,1:…}.
|
|
7
|
+
//
|
|
8
|
+
// Кадр = байтовый буфер; дельта (P-frame) = dirty-rect с байтами;
|
|
9
|
+
// keyframe (I-frame) = полный буфер кадра. Память внешняя: сервер
|
|
10
|
+
// сам владеет «текущим кадром», current() отдаёт его копию.
|
|
11
|
+
//
|
|
12
|
+
// Запуск: npx ts-node replay/canvas-socket.test.ts
|
|
13
|
+
// ============================================================
|
|
14
|
+
|
|
15
|
+
import express from 'express'
|
|
16
|
+
import {createServer} from 'http'
|
|
17
|
+
import type {AddressInfo} from 'net'
|
|
18
|
+
import {Server as SocketIOServer} from 'socket.io'
|
|
19
|
+
import {io} from 'socket.io-client'
|
|
20
|
+
import {listen as createListenPair} from '../src/Common/events/Listen'
|
|
21
|
+
import {createRpcClientHub} from '../src/Common/rcp/rpc-clientHub'
|
|
22
|
+
import {createRpcServerAuto} from '../src/Common/rcp/rpc-server-auto'
|
|
23
|
+
import {replayListen, exposeReplay, replaySubscribe, ReplayRemote} from '../src/Common/events/replay-index'
|
|
24
|
+
|
|
25
|
+
const W = 64, H = 48, BPP = 4 // 12288 байт на кадр
|
|
26
|
+
|
|
27
|
+
let fails = 0
|
|
28
|
+
const ok = (condition: any, message: string) => {
|
|
29
|
+
if (!condition) { fails++; console.log(' FAIL', message) }
|
|
30
|
+
else console.log(' OK ', message)
|
|
31
|
+
}
|
|
32
|
+
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
|
33
|
+
|
|
34
|
+
/** Дельта-кадр: прямоугольник пикселей с СЫРЫМИ байтами (как canvas putImageData). */
|
|
35
|
+
type Rect = {x: number, y: number, w: number, h: number, bytes: Uint8Array}
|
|
36
|
+
|
|
37
|
+
function blitRect(screen: Uint8Array, r: Rect) {
|
|
38
|
+
for (let row = 0; row < r.h; row++) {
|
|
39
|
+
const src = r.bytes.subarray(row * r.w * BPP, (row + 1) * r.w * BPP)
|
|
40
|
+
screen.set(src, ((r.y + row) * W + r.x) * BPP)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fillRect(x: number, y: number, w: number, h: number, rgba: number[]): Rect {
|
|
45
|
+
const bytes = new Uint8Array(w * h * BPP)
|
|
46
|
+
for (let i = 0; i < w * h; i++) bytes.set(rgba, i * BPP)
|
|
47
|
+
return {x, y, w, h, bytes}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sameBytes = (a: Uint8Array, b: Uint8Array) =>
|
|
51
|
+
a.byteLength == b.byteLength && Buffer.compare(Buffer.from(a.buffer, a.byteOffset, a.byteLength), Buffer.from(b.buffer, b.byteOffset, b.byteLength)) == 0
|
|
52
|
+
|
|
53
|
+
// принятый по проводу бинарь приходит как Buffer/Uint8Array — нормализуем вид
|
|
54
|
+
const asBytes = (v: any): Uint8Array => v instanceof Uint8Array ? v : new Uint8Array(v.buffer ?? v)
|
|
55
|
+
|
|
56
|
+
async function startRealServer(object: object) {
|
|
57
|
+
const app = express()
|
|
58
|
+
const httpServer = createServer(app)
|
|
59
|
+
const ioServer = new SocketIOServer(httpServer, {maxHttpBufferSize: 1e8})
|
|
60
|
+
ioServer.on('connection', socket => {
|
|
61
|
+
const [disconnect, disconnectListen] = createListenPair<[]>()
|
|
62
|
+
socket.on('disconnect', () => disconnect())
|
|
63
|
+
createRpcServerAuto({
|
|
64
|
+
socket: {
|
|
65
|
+
emit: (key, data) => socket.emit(key, data),
|
|
66
|
+
on: (key, cb) => socket.on(key, cb),
|
|
67
|
+
},
|
|
68
|
+
socketKey: 'canvas',
|
|
69
|
+
object,
|
|
70
|
+
disconnectListen,
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
await new Promise<void>((resolve, reject) => {
|
|
74
|
+
httpServer.once('error', reject)
|
|
75
|
+
httpServer.listen(0, '127.0.0.1', resolve)
|
|
76
|
+
})
|
|
77
|
+
return {
|
|
78
|
+
port: (httpServer.address() as AddressInfo).port,
|
|
79
|
+
close: () => new Promise<void>(resolve => {
|
|
80
|
+
ioServer.close()
|
|
81
|
+
httpServer.close(() => resolve())
|
|
82
|
+
}),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function connectViewer(port: number) {
|
|
87
|
+
const hub = createRpcClientHub(
|
|
88
|
+
() => io(`http://127.0.0.1:${port}`, {transports: ['websocket'], forceNew: true}),
|
|
89
|
+
r => ({api: r<any>('canvas')}) as const,
|
|
90
|
+
)
|
|
91
|
+
const clients = await hub.setToken(null)
|
|
92
|
+
await clients.api.readyStrict()
|
|
93
|
+
const deep = clients.api.func as any
|
|
94
|
+
const remote: ReplayRemote<[Rect]> = {
|
|
95
|
+
line: deep.replay.line,
|
|
96
|
+
since: (s: number) => deep.replay.since(s),
|
|
97
|
+
keyframe: () => deep.replay.keyframe(),
|
|
98
|
+
}
|
|
99
|
+
return {remote, close: () => hub.socket?.disconnect?.()}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function main() {
|
|
103
|
+
console.log('\n[canvas] raw RGBA byte stream over a real Socket.IO wire')
|
|
104
|
+
|
|
105
|
+
// ============ сервер: канвас + линия дельт ============
|
|
106
|
+
const frame = new Uint8Array(W * H * BPP) // внешняя память: текущий кадр владеет сервер
|
|
107
|
+
const [emitRect, line] = replayListen<[Rect]>({
|
|
108
|
+
current: () => [{x: 0, y: 0, w: W, h: H, bytes: frame.slice()}], // keyframe = полный кадр
|
|
109
|
+
history: 24,
|
|
110
|
+
})
|
|
111
|
+
function draw(r: Rect) {
|
|
112
|
+
blitRect(frame, r)
|
|
113
|
+
emitRect(r)
|
|
114
|
+
}
|
|
115
|
+
const server = await startRealServer({replay: exposeReplay(line)})
|
|
116
|
+
|
|
117
|
+
let px = 0
|
|
118
|
+
function paintTick() {
|
|
119
|
+
draw(fillRect(px, 10, 4, 4, [0, 0, 0, 255])) // стереть старый квадрат
|
|
120
|
+
px = (px + 4) % (W - 4)
|
|
121
|
+
draw(fillRect(px, 10, 4, 4, [255, 64, 0, 255])) // нарисовать новый
|
|
122
|
+
}
|
|
123
|
+
// фон + первые кадры до подключения зрителя
|
|
124
|
+
draw(fillRect(0, 0, W, H, [0, 0, 0, 255]))
|
|
125
|
+
for (let i = 0; i < 10; i++) paintTick()
|
|
126
|
+
|
|
127
|
+
const closers: (() => void)[] = []
|
|
128
|
+
try {
|
|
129
|
+
// ============ зритель приходит ПОСЛЕ 21 события ============
|
|
130
|
+
const viewer = await connectViewer(server.port)
|
|
131
|
+
closers.push(viewer.close)
|
|
132
|
+
const screen = new Uint8Array(W * H * BPP)
|
|
133
|
+
let deliveries = 0
|
|
134
|
+
let lastSeq = -1
|
|
135
|
+
let binaryOnWire = true
|
|
136
|
+
const sub = replaySubscribe<[Rect]>(viewer.remote, function applyRect(r) {
|
|
137
|
+
if (!ArrayBuffer.isView(r.bytes)) binaryOnWire = false // {0:…,1:…} — провал passthrough
|
|
138
|
+
blitRect(screen, {...r, bytes: asBytes(r.bytes)})
|
|
139
|
+
deliveries++
|
|
140
|
+
}, {onSeq: s => lastSeq = s})
|
|
141
|
+
await sub.ready
|
|
142
|
+
ok(binaryOnWire, 'bytes traveled as REAL binary (TypedArray/Buffer), not a {0:…} dictionary')
|
|
143
|
+
ok(deliveries == 1, `late viewer got 1 keyframe, not a ${line.head()}-event backlog`)
|
|
144
|
+
ok(sameBytes(screen, frame), 'keyframe is byte-for-byte identical to the server canvas')
|
|
145
|
+
|
|
146
|
+
// ============ live-дельты ============
|
|
147
|
+
for (let i = 0; i < 5; i++) paintTick()
|
|
148
|
+
await delay(150)
|
|
149
|
+
ok(sameBytes(screen, frame), 'live dirty-rect deltas keep the canvas pixel-perfect')
|
|
150
|
+
|
|
151
|
+
// ============ лаг → реконнект хвостом дельт ============
|
|
152
|
+
sub()
|
|
153
|
+
paintTick(); paintTick() // 4 события мимо зрителя, журнал (24) их держит
|
|
154
|
+
let tailDeliveries = 0
|
|
155
|
+
const sub2 = replaySubscribe<[Rect]>(viewer.remote, function applyRect(r) {
|
|
156
|
+
blitRect(screen, {...r, bytes: asBytes(r.bytes)})
|
|
157
|
+
tailDeliveries++
|
|
158
|
+
}, {since: lastSeq, onSeq: s => lastSeq = s})
|
|
159
|
+
await sub2.ready
|
|
160
|
+
ok(tailDeliveries == 4, `short lag → tail of 4 rect deltas, no keyframe (got ${tailDeliveries})`)
|
|
161
|
+
ok(sameBytes(screen, frame), 'after catch-up the canvas is pixel-perfect again')
|
|
162
|
+
|
|
163
|
+
// ============ долгий лаг → журнал вытеснен → keyframe ============
|
|
164
|
+
sub2()
|
|
165
|
+
for (let i = 0; i < 30; i++) paintTick() // 60 событий >> history 24
|
|
166
|
+
let resync = 0
|
|
167
|
+
const sub3 = replaySubscribe<[Rect]>(viewer.remote, function applyRect(r) {
|
|
168
|
+
blitRect(screen, {...r, bytes: asBytes(r.bytes)})
|
|
169
|
+
resync++
|
|
170
|
+
}, {since: lastSeq})
|
|
171
|
+
await sub3.ready
|
|
172
|
+
ok(resync == 1, `long lag → 60 missed deltas collapsed into 1 keyframe (got ${resync})`)
|
|
173
|
+
ok(sameBytes(screen, frame), 'keyframe recovery is byte-for-byte exact')
|
|
174
|
+
sub3()
|
|
175
|
+
|
|
176
|
+
const kb = (n: number) => `${Math.round(n / 102.4) / 10}kb`
|
|
177
|
+
console.log(`\n frame = ${kb(frame.byteLength)} raw; mangled {0:…} JSON of the same frame would be ~${kb(JSON.stringify({...frame.slice(0, 256)}).length * (frame.byteLength / 256))}`)
|
|
178
|
+
console.log(fails ? `\n${fails} FAILED` : '\nall passed')
|
|
179
|
+
} finally {
|
|
180
|
+
for (const close of closers) close()
|
|
181
|
+
await delay(20)
|
|
182
|
+
await server.close()
|
|
183
|
+
}
|
|
184
|
+
if (fails) process.exit(1)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
main().catch(e => { console.error(e); process.exit(1) })
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// replay/coalesce.test.ts
|
|
3
|
+
//
|
|
4
|
+
// Слой D: key-level coalescing в conflateReplay (opts.keyOf).
|
|
5
|
+
// Пока клиент лагает — карта «ключ → последний конверт», восстановление
|
|
6
|
+
// хвостом схлопнутых дельт вместо полного keyframe; неключуемое событие
|
|
7
|
+
// или переполнение maxKeys → деградация до keyframe recovery.
|
|
8
|
+
// Часть 1 — универсальная линия (тики по символам), часть 2 — деградации,
|
|
9
|
+
// часть 3 — зеркало стора со storePatchKey (включая вложенные пути).
|
|
10
|
+
// Запуск:
|
|
11
|
+
// npx ts-node replay/coalesce.test.ts
|
|
12
|
+
// ============================================================
|
|
13
|
+
|
|
14
|
+
import {createStore} from '../src/Common/Observe/store'
|
|
15
|
+
import {flushReactive} from '../src/Common/Observe/reactive'
|
|
16
|
+
import {replayListen, replaySubscribe, ReplayRemote, conflateReplay} from '../src/Common/events/replay-index'
|
|
17
|
+
import {exposeStoreReplay, syncStoreReplay, storePatchKey} from '../src/Common/Observe/store-replay'
|
|
18
|
+
|
|
19
|
+
let fails = 0
|
|
20
|
+
const ok = (condition: any, message: string) => {
|
|
21
|
+
if (!condition) { fails++; console.log(' FAIL', message) }
|
|
22
|
+
else console.log(' OK ', message)
|
|
23
|
+
}
|
|
24
|
+
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
|
25
|
+
const json = (v: any) => JSON.stringify(v)
|
|
26
|
+
const ascendingUnique = (seqs: number[]) => seqs.every((s, i) => i == 0 || s > seqs[i - 1])
|
|
27
|
+
|
|
28
|
+
async function waitFor(label: string, cond: () => boolean) {
|
|
29
|
+
for (let i = 0; i < 120; i++) {
|
|
30
|
+
if (cond()) return
|
|
31
|
+
await delay(10)
|
|
32
|
+
}
|
|
33
|
+
throw new Error(`timeout: ${label}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Msg =
|
|
37
|
+
| {type: 'tick', sym: string, px: number}
|
|
38
|
+
| {type: 'snap', all: Record<string, number>}
|
|
39
|
+
|
|
40
|
+
type World = {
|
|
41
|
+
units: Record<string, {hp: number, x: number}>
|
|
42
|
+
tick: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function main() {
|
|
46
|
+
console.log('\n[coalesce] generic line: last-per-key tail instead of a full keyframe')
|
|
47
|
+
{
|
|
48
|
+
const state: Record<string, number> = {}
|
|
49
|
+
const [emit, replay] = replayListen<[Msg]>({
|
|
50
|
+
current: () => [{type: 'snap', all: {...state}}],
|
|
51
|
+
history: 1000,
|
|
52
|
+
})
|
|
53
|
+
const tick = (sym: string, px: number) => { state[sym] = px; emit({type: 'tick', sym, px}) }
|
|
54
|
+
const buf = {v: 0}
|
|
55
|
+
const gated = conflateReplay(replay, {
|
|
56
|
+
pending: () => buf.v, highWater: 5, pollMs: 10,
|
|
57
|
+
keyOf: m => m.type == 'tick' ? m.sym : null,
|
|
58
|
+
})
|
|
59
|
+
let envelopes = 0
|
|
60
|
+
const remote: ReplayRemote<[Msg]> = {
|
|
61
|
+
line: {on: (cb: any) => gated.api.line.on((ev: any) => { envelopes++; cb(ev) })},
|
|
62
|
+
since: s => gated.api.since(s),
|
|
63
|
+
keyframe: () => gated.api.keyframe(),
|
|
64
|
+
}
|
|
65
|
+
const book: Record<string, number> = {}
|
|
66
|
+
const got: Msg[] = []
|
|
67
|
+
const seqs: number[] = []
|
|
68
|
+
const sub = replaySubscribe(remote, function applyMsg(m: Msg) {
|
|
69
|
+
got.push(m)
|
|
70
|
+
if (m.type == 'snap') { for (const k of Object.keys(book)) delete book[k]; Object.assign(book, m.all) }
|
|
71
|
+
else book[m.sym] = m.px
|
|
72
|
+
}, {onSeq: s => seqs.push(s)})
|
|
73
|
+
await sub.ready
|
|
74
|
+
ok(got.length == 1 && got[0].type == 'snap', 'fresh subscriber got the snap keyframe')
|
|
75
|
+
|
|
76
|
+
tick('A', 100)
|
|
77
|
+
tick('B', 200)
|
|
78
|
+
ok(json(book) == json(state), 'below highWater ticks pass through untouched')
|
|
79
|
+
const s0 = gated.stats()
|
|
80
|
+
ok(s0.coalesced == 0 && s0.flushes == 0 && s0.keyframes == 0, 'gate was transparent so far')
|
|
81
|
+
|
|
82
|
+
// ============ затор: тики схлопываются по символу, клиенту — тишина ============
|
|
83
|
+
buf.v = 10
|
|
84
|
+
for (let px = 101; px <= 105; px++) tick('A', px) // 5 тиков одного ключа
|
|
85
|
+
tick('B', 201)
|
|
86
|
+
tick('A', 106)
|
|
87
|
+
ok(book['A'] == 100 && book['B'] == 200, 'over highWater: nothing reaches the client')
|
|
88
|
+
ok(gated.stats().conflating, 'gate is conflating')
|
|
89
|
+
ok(gated.stats().coalesced == 5, `5 ticks of A superseded in place (coalesced=${gated.stats().coalesced})`)
|
|
90
|
+
|
|
91
|
+
// ============ буфер слился в тишине → хвост последних значений, НЕ keyframe ============
|
|
92
|
+
const envBefore = envelopes
|
|
93
|
+
buf.v = 0
|
|
94
|
+
await waitFor('poll flush', () => book['A'] == 106)
|
|
95
|
+
ok(book['A'] == 106 && book['B'] == 201, 'client converged to last values')
|
|
96
|
+
ok(envelopes - envBefore == 2, `7 missed ticks collapsed into 2 envelopes (got ${envelopes - envBefore})`)
|
|
97
|
+
ok(gated.stats().flushes == 1 && gated.stats().keyframes == 0, 'recovery was a coalesced tail, no keyframe')
|
|
98
|
+
ok(!got.some(m => m.type == 'tick' && m.sym == 'A' && m.px > 100 && m.px < 106), 'intermediate A values never crossed the wire')
|
|
99
|
+
|
|
100
|
+
tick('A', 107)
|
|
101
|
+
ok(book['A'] == 107, 'live ticks resume after recovery')
|
|
102
|
+
|
|
103
|
+
// ============ восстановление ближайшим же событием, без ожидания опроса ============
|
|
104
|
+
buf.v = 10
|
|
105
|
+
tick('C', 1)
|
|
106
|
+
ok(book['C'] == null, 'tick while conflating is held, not delivered')
|
|
107
|
+
buf.v = 0
|
|
108
|
+
tick('D', 2)
|
|
109
|
+
ok(book['C'] == 1 && book['D'] == 2, 'drained + next event → tail flushed immediately, incl. that event')
|
|
110
|
+
ok(gated.stats().flushes == 2 && gated.stats().keyframes == 0, 'again a tail, not a keyframe')
|
|
111
|
+
|
|
112
|
+
ok(ascendingUnique(seqs), `delivered seqs strictly ascending: ${seqs.join(',')}`)
|
|
113
|
+
|
|
114
|
+
// ============ неключуемое событие → эпизод деградирует до keyframe ============
|
|
115
|
+
buf.v = 10
|
|
116
|
+
tick('E', 5)
|
|
117
|
+
emit({type: 'snap', all: {...state}}) // keyOf → null: схлопнуть нельзя
|
|
118
|
+
const envBeforeKf = envelopes
|
|
119
|
+
buf.v = 0
|
|
120
|
+
tick('F', 6)
|
|
121
|
+
ok(gated.stats().keyframes == 1, 'unkeyable event degraded the episode to keyframe recovery')
|
|
122
|
+
ok(envelopes - envBeforeKf == 1, `recovery cost exactly 1 envelope (got ${envelopes - envBeforeKf})`)
|
|
123
|
+
ok(book['E'] == 5 && book['F'] == 6 && json(book) == json(state), 'keyframe covered both held and unkeyable events')
|
|
124
|
+
|
|
125
|
+
// ============ close: ворота мертвы навсегда ============
|
|
126
|
+
const before = got.length
|
|
127
|
+
gated.close()
|
|
128
|
+
tick('G', 7)
|
|
129
|
+
ok(got.length == before, 'after close() nothing reaches the client')
|
|
130
|
+
sub()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
console.log('\n[coalesce] maxKeys overflow degrades to keyframe; killer property holds')
|
|
134
|
+
{
|
|
135
|
+
const state: Record<string, number> = {}
|
|
136
|
+
const [emit, replay] = replayListen<[Msg]>({
|
|
137
|
+
current: () => [{type: 'snap', all: {...state}}],
|
|
138
|
+
history: 100,
|
|
139
|
+
})
|
|
140
|
+
const tick = (sym: string, px: number) => { state[sym] = px; emit({type: 'tick', sym, px}) }
|
|
141
|
+
const buf = {v: 10}
|
|
142
|
+
const gated = conflateReplay(replay, {
|
|
143
|
+
pending: () => buf.v, highWater: 5, pollMs: 10,
|
|
144
|
+
keyOf: m => m.type == 'tick' ? m.sym : null,
|
|
145
|
+
maxKeys: 2,
|
|
146
|
+
})
|
|
147
|
+
const book: Record<string, number> = {}
|
|
148
|
+
const sub = replaySubscribe<[Msg]>(
|
|
149
|
+
{line: gated.api.line, since: s => gated.api.since(s), keyframe: () => gated.api.keyframe()},
|
|
150
|
+
m => { if (m.type == 'snap') Object.assign(book, m.all); else book[m.sym] = m.px })
|
|
151
|
+
await sub.ready
|
|
152
|
+
tick('A', 1); tick('B', 2); tick('C', 3) // третий ключ не влезает в карту
|
|
153
|
+
buf.v = 0
|
|
154
|
+
await waitFor('keyframe recovery', () => book['C'] == 3)
|
|
155
|
+
ok(gated.stats().keyframes == 1 && gated.stats().flushes == 0, 'over maxKeys → one keyframe, memory stays bounded by keys')
|
|
156
|
+
ok(json(book) == json(state), 'client converged via the keyframe')
|
|
157
|
+
sub(); gated.close()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log('\n[coalesce] close() mid-episode: held tail dies with the gate')
|
|
161
|
+
{
|
|
162
|
+
const state: Record<string, number> = {}
|
|
163
|
+
const [emit, replay] = replayListen<[Msg]>({
|
|
164
|
+
current: () => [{type: 'snap', all: {...state}}],
|
|
165
|
+
history: 100,
|
|
166
|
+
})
|
|
167
|
+
const tick = (sym: string, px: number) => { state[sym] = px; emit({type: 'tick', sym, px}) }
|
|
168
|
+
const buf = {v: 10}
|
|
169
|
+
const gated = conflateReplay(replay, {
|
|
170
|
+
pending: () => buf.v, highWater: 5, pollMs: 10,
|
|
171
|
+
keyOf: m => m.type == 'tick' ? m.sym : null,
|
|
172
|
+
})
|
|
173
|
+
const book: Record<string, number> = {}
|
|
174
|
+
let delivered = 0
|
|
175
|
+
const sub = replaySubscribe<[Msg]>(
|
|
176
|
+
{line: gated.api.line, since: s => gated.api.since(s), keyframe: () => gated.api.keyframe()},
|
|
177
|
+
m => { delivered++; if (m.type == 'snap') Object.assign(book, m.all); else book[m.sym] = m.px })
|
|
178
|
+
await sub.ready
|
|
179
|
+
const base = delivered
|
|
180
|
+
tick('A', 1); tick('B', 2)
|
|
181
|
+
ok(gated.stats().conflating, 'episode is holding a key map')
|
|
182
|
+
|
|
183
|
+
gated.close() // дисконнект посреди эпизода: карта должна умереть вместе с воротами
|
|
184
|
+
buf.v = 0
|
|
185
|
+
await delay(60) // опрос остановлен close() — восстановления не будет
|
|
186
|
+
tick('C', 3) // и события в ворота больше не проходят (журнал линии живёт дальше)
|
|
187
|
+
ok(delivered == base && book['A'] == null, 'nothing leaks after close(): no tail, no keyframe, no live')
|
|
188
|
+
ok(json(gated.stats()) == json({conflating: true, dropped: 0, keyframes: 0, coalesced: 0, flushes: 0}), 'stats frozen at close time')
|
|
189
|
+
sub()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
console.log('\n[coalesce] store mirror: only changed keys travel, nested paths stay exact')
|
|
193
|
+
{
|
|
194
|
+
const backend = createStore<World>({units: {a: {hp: 100, x: 0}, b: {hp: 50, x: 5}}, tick: 0}, {drain: 'micro'})
|
|
195
|
+
const exposed = exposeStoreReplay(backend, {history: 200})
|
|
196
|
+
const buf = {v: 0}
|
|
197
|
+
const gated = conflateReplay(exposed.replay, {
|
|
198
|
+
pending: () => buf.v, highWater: 3, pollMs: 10,
|
|
199
|
+
keyOf: storePatchKey,
|
|
200
|
+
})
|
|
201
|
+
let envelopes = 0
|
|
202
|
+
const remote: ReplayRemote<any> = {
|
|
203
|
+
line: {on: (cb: any) => gated.api.line.on((ev: any) => { envelopes++; cb(ev) })},
|
|
204
|
+
since: async s => { await delay(10); return gated.api.since(s) },
|
|
205
|
+
keyframe: async () => { await delay(10); return gated.api.keyframe() },
|
|
206
|
+
}
|
|
207
|
+
const mirror = createStore<World>({units: {}, tick: -1})
|
|
208
|
+
const seqs: number[] = []
|
|
209
|
+
const sub = syncStoreReplay(mirror, remote, {onSeq: s => seqs.push(s)})
|
|
210
|
+
await sub.ready
|
|
211
|
+
ok(json(mirror.state) == json(backend.snapshot()), 'fresh mirror converged via keyframe')
|
|
212
|
+
|
|
213
|
+
// ============ клиент завис: 20 патчей двух путей → 2 конверта ============
|
|
214
|
+
buf.v = 50
|
|
215
|
+
for (let i = 1; i <= 10; i++) {
|
|
216
|
+
backend.state.units['a'].hp = 100 + i
|
|
217
|
+
backend.state.tick = i
|
|
218
|
+
await flushReactive(backend.state)
|
|
219
|
+
}
|
|
220
|
+
ok(mirror.state.tick == 0, 'stalled client got NO deltas')
|
|
221
|
+
const envBefore = envelopes
|
|
222
|
+
buf.v = 0
|
|
223
|
+
await waitFor('coalesced recovery', () => mirror.state.tick == 10)
|
|
224
|
+
ok(json(mirror.state) == json(backend.snapshot()), 'recovered mirror equals backend')
|
|
225
|
+
ok(envelopes - envBefore == 2, `20 missed patches collapsed into 2 envelopes (got ${envelopes - envBefore})`)
|
|
226
|
+
ok(gated.stats().flushes == 1 && gated.stats().keyframes == 0, 'the big store never travelled — no keyframe')
|
|
227
|
+
|
|
228
|
+
// ============ вложенные пути: лист → замена предка → лист, порядок по seq ============
|
|
229
|
+
buf.v = 50
|
|
230
|
+
backend.state.units['b'].hp = 55
|
|
231
|
+
await flushReactive(backend.state)
|
|
232
|
+
backend.state.units['b'] = {hp: 70, x: 7}
|
|
233
|
+
await flushReactive(backend.state)
|
|
234
|
+
backend.state.units['b'].x = 9
|
|
235
|
+
await flushReactive(backend.state)
|
|
236
|
+
buf.v = 0
|
|
237
|
+
await waitFor('nested recovery', () => mirror.state.units['b']?.x == 9)
|
|
238
|
+
ok(json(mirror.state) == json(backend.snapshot()), 'ancestor/descendant interleave replayed bit-exact')
|
|
239
|
+
ok(gated.stats().keyframes == 0, 'still no keyframe: tails were enough')
|
|
240
|
+
|
|
241
|
+
// ============ реконнект через since: журнал полон, дырки схлопывания не мешают ============
|
|
242
|
+
const at = sub.seq()
|
|
243
|
+
sub()
|
|
244
|
+
backend.state.tick = 11
|
|
245
|
+
await flushReactive(backend.state)
|
|
246
|
+
const sub2 = syncStoreReplay(mirror, remote, {since: at})
|
|
247
|
+
await sub2.ready
|
|
248
|
+
ok(json(mirror.state) == json(backend.snapshot()), 'reconnect via since converged from the journal tail')
|
|
249
|
+
|
|
250
|
+
ok(ascendingUnique(seqs), `delivered seqs strictly ascending: ${seqs.join(',')}`)
|
|
251
|
+
sub2()
|
|
252
|
+
gated.close()
|
|
253
|
+
exposed.close()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
console.log(fails ? `\n${fails} FAILED` : '\nall passed')
|
|
257
|
+
if (fails) process.exit(1)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
main().catch(e => { console.error(e); process.exit(1) })
|