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,228 @@
|
|
|
1
|
+
// Acceptance oracle for the Peer SDK (library-uplift-tasks task 2): the happy
|
|
2
|
+
// path "two accounts see each other's store and can promote to direct" in ~10
|
|
3
|
+
// user lines, over a real Socket.IO/RPC wire, NEXT TO legacy rpc keys on the
|
|
4
|
+
// same connection. Direct = fake WebRTC runtime (injected factory, as designed).
|
|
5
|
+
import express from 'express'
|
|
6
|
+
import {createServer} from 'http'
|
|
7
|
+
import type {AddressInfo} from 'net'
|
|
8
|
+
import {Server as SocketIOServer} from 'socket.io'
|
|
9
|
+
import {io} from 'socket.io-client'
|
|
10
|
+
import {listen as createListenPair} from '../src/Common/events/Listen'
|
|
11
|
+
import {RtcDataChannel, RtcPeerConnection} from '../src/Common/events/replay-index'
|
|
12
|
+
import {flushReactive} from '../src/Common/Observe/reactive'
|
|
13
|
+
import {createPeerClient, createPeerHost} from '../src/Common/peer/peer-index'
|
|
14
|
+
import {createRpcClientHub} from '../src/Common/rcp/rpc-clientHub'
|
|
15
|
+
import {createRpcServerAuto} from '../src/Common/rcp/rpc-server-auto'
|
|
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
|
+
|
|
25
|
+
async function waitFor(label: string, cond: () => boolean) {
|
|
26
|
+
for (let i = 0; i < 200; i++) {
|
|
27
|
+
if (cond()) return
|
|
28
|
+
await delay(20)
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`timeout: ${label}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ============ fake in-proc WebRTC runtime (same shape as route-webrtc oracle) ============
|
|
34
|
+
function createFakeRtcNet() {
|
|
35
|
+
let n = 0
|
|
36
|
+
const pcs = new Map<string, any>()
|
|
37
|
+
const stats = {ice: 0, channels: 0}
|
|
38
|
+
function makeDcPair(): [RtcDataChannel, RtcDataChannel] {
|
|
39
|
+
let closed = false
|
|
40
|
+
function side(other: () => any): any {
|
|
41
|
+
const me: any = {onopen: null, onmessage: null, onclose: null, onerror: null}
|
|
42
|
+
me.send = (data: string) => { if (!closed) setTimeout(function deliverDc() { if (!closed) other().onmessage?.({data}) }, 0) }
|
|
43
|
+
me.close = () => {
|
|
44
|
+
if (closed) return
|
|
45
|
+
closed = true
|
|
46
|
+
setTimeout(function closeDc() { me.onclose?.(); other().onclose?.() }, 0)
|
|
47
|
+
}
|
|
48
|
+
return me
|
|
49
|
+
}
|
|
50
|
+
let a: any, b: any
|
|
51
|
+
a = side(() => b)
|
|
52
|
+
b = side(() => a)
|
|
53
|
+
return [a, b]
|
|
54
|
+
}
|
|
55
|
+
function tryConnect(me: any) {
|
|
56
|
+
if (!me.local || !me.remote) return
|
|
57
|
+
const other = pcs.get(me.remote.sdp)
|
|
58
|
+
if (!other || !other.local || !other.remote || other.remote.sdp != me.id) return
|
|
59
|
+
const initiator = me.pendingDcs.length ? me : other
|
|
60
|
+
const responder = initiator == me ? other : me
|
|
61
|
+
if (!initiator.pendingDcs.length || initiator.linked) return
|
|
62
|
+
initiator.linked = responder.linked = true
|
|
63
|
+
for (const dc of initiator.pendingDcs) {
|
|
64
|
+
const [a, b] = makeDcPair()
|
|
65
|
+
dc.attach(a)
|
|
66
|
+
stats.channels++
|
|
67
|
+
setTimeout(function announceChannel() {
|
|
68
|
+
responder.pc.ondatachannel?.({channel: b})
|
|
69
|
+
dc.fireOpen()
|
|
70
|
+
}, 0)
|
|
71
|
+
}
|
|
72
|
+
initiator.pendingDcs = []
|
|
73
|
+
}
|
|
74
|
+
function pc(): RtcPeerConnection {
|
|
75
|
+
const id = 'sdp-' + (++n)
|
|
76
|
+
const me: any = {id, local: null, remote: null, pendingDcs: [], linked: false}
|
|
77
|
+
const api: RtcPeerConnection = {
|
|
78
|
+
createDataChannel() {
|
|
79
|
+
let real: any = null
|
|
80
|
+
const shell: any = {onopen: null, onmessage: null, onclose: null, onerror: null}
|
|
81
|
+
shell.send = (data: string) => real?.send(data)
|
|
82
|
+
shell.close = () => real?.close()
|
|
83
|
+
me.pendingDcs.push({
|
|
84
|
+
attach(a: any) {
|
|
85
|
+
real = a
|
|
86
|
+
if (!a) return
|
|
87
|
+
a.onmessage = (ev: any) => shell.onmessage?.(ev)
|
|
88
|
+
a.onclose = () => shell.onclose?.()
|
|
89
|
+
},
|
|
90
|
+
fireOpen() { shell.onopen?.() },
|
|
91
|
+
})
|
|
92
|
+
return shell
|
|
93
|
+
},
|
|
94
|
+
createOffer: async () => ({type: 'offer', sdp: id}),
|
|
95
|
+
createAnswer: async () => ({type: 'answer', sdp: id}),
|
|
96
|
+
setLocalDescription(d) {
|
|
97
|
+
me.local = d
|
|
98
|
+
setTimeout(function trickleIce() { api.onicecandidate?.({candidate: {via: id}}) }, 0)
|
|
99
|
+
},
|
|
100
|
+
setRemoteDescription(d) { me.remote = d; tryConnect(me) },
|
|
101
|
+
addIceCandidate() { stats.ice++ },
|
|
102
|
+
close() { me.pendingDcs = [] },
|
|
103
|
+
onicecandidate: null,
|
|
104
|
+
ondatachannel: null,
|
|
105
|
+
}
|
|
106
|
+
me.pc = api
|
|
107
|
+
pcs.set(id, me)
|
|
108
|
+
return api
|
|
109
|
+
}
|
|
110
|
+
return {pc, stats}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type World = {cursor: {x: number, y: number}, name?: string}
|
|
114
|
+
|
|
115
|
+
async function main() {
|
|
116
|
+
console.log('\n[peer-sdk] two accounts, real socket, legacy keys alongside')
|
|
117
|
+
|
|
118
|
+
const rtcNet = createFakeRtcNet()
|
|
119
|
+
|
|
120
|
+
// ================= SERVER: legacy object + peer fragment =================
|
|
121
|
+
const host = createPeerHost()
|
|
122
|
+
const app = express()
|
|
123
|
+
const httpServer = createServer(app)
|
|
124
|
+
const ioServer = new SocketIOServer(httpServer)
|
|
125
|
+
ioServer.on('connection', socket => {
|
|
126
|
+
const account = String(socket.handshake.auth?.account)
|
|
127
|
+
const peer = host.connection(account)
|
|
128
|
+
const [disconnect, disconnectListen] = createListenPair<[]>()
|
|
129
|
+
socket.on('disconnect', () => { disconnect(); peer.close() })
|
|
130
|
+
createRpcServerAuto({
|
|
131
|
+
socket: {emit: (key, data) => socket.emit(key, data), on: (key, cb) => socket.on(key, cb)},
|
|
132
|
+
socketKey: 'app',
|
|
133
|
+
object: {
|
|
134
|
+
legacyHello: (name: string) => 'hello ' + name, // старый код живёт рядом, нетронутый
|
|
135
|
+
peer: peer.fragment,
|
|
136
|
+
},
|
|
137
|
+
disconnectListen,
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
await new Promise<void>((resolve, reject) => {
|
|
141
|
+
httpServer.once('error', reject)
|
|
142
|
+
httpServer.listen(0, '127.0.0.1', resolve)
|
|
143
|
+
})
|
|
144
|
+
const port = (httpServer.address() as AddressInfo).port
|
|
145
|
+
|
|
146
|
+
async function connect(account: string) {
|
|
147
|
+
const hub = createRpcClientHub(
|
|
148
|
+
() => io(`http://127.0.0.1:${port}`, {transports: ['websocket'], forceNew: true, auth: {account}}),
|
|
149
|
+
r => ({api: r<any>('app')}) as const,
|
|
150
|
+
)
|
|
151
|
+
const clients = await hub.setToken(null)
|
|
152
|
+
await clients.api.readyStrict()
|
|
153
|
+
return {func: clients.api.func, closeSocket: () => hub.socket?.disconnect?.()}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ================= CLIENTS: the happy path =================
|
|
157
|
+
const connA = await connect('a')
|
|
158
|
+
const connB = await connect('b')
|
|
159
|
+
const a = createPeerClient<World>({
|
|
160
|
+
remote: connA.func.peer, account: 'a',
|
|
161
|
+
initial: {cursor: {x: 0, y: 0}, name: 'alice'},
|
|
162
|
+
rtc: rtcNet.pc, drain: 'micro',
|
|
163
|
+
})
|
|
164
|
+
const b = createPeerClient<World>({
|
|
165
|
+
remote: connB.func.peer, account: 'b',
|
|
166
|
+
initial: {cursor: {x: 100, y: 100}, name: 'bob'},
|
|
167
|
+
rtc: rtcNet.pc, drain: 'micro',
|
|
168
|
+
})
|
|
169
|
+
const aSeenByB = b.peer('a')
|
|
170
|
+
const bSeenByA = a.peer('b')
|
|
171
|
+
await Promise.all([aSeenByB.ready, bSeenByA.ready])
|
|
172
|
+
// ^^^ вся клиентская сторона happy path: connect + createPeerClient + peer()
|
|
173
|
+
|
|
174
|
+
ok(await connA.func.legacyHello('world') == 'hello world', 'legacy rpc key works untouched on the same connection')
|
|
175
|
+
await waitFor('initial mirrors', () => json(aSeenByB.store.state) == json(a.store.snapshot())
|
|
176
|
+
&& json(bSeenByA.store.state) == json(b.store.snapshot()))
|
|
177
|
+
ok(true, 'b mirrors a via relay, a mirrors b symmetrically')
|
|
178
|
+
|
|
179
|
+
a.store.state.cursor.x = 5
|
|
180
|
+
await flushReactive(a.store.state)
|
|
181
|
+
await waitFor('relay live patch', () => aSeenByB.store.state.cursor.x == 5)
|
|
182
|
+
ok(aSeenByB.route() == 'relay', 'live updates flow while the route is relay')
|
|
183
|
+
|
|
184
|
+
// ================= direct promotion =================
|
|
185
|
+
const seqBefore = aSeenByB.seq()
|
|
186
|
+
const res = await aSeenByB.promoteDirect()
|
|
187
|
+
ok(res.ok && aSeenByB.route() == 'direct', 'b -> a promoted to direct via webrtc signaling')
|
|
188
|
+
a.store.state.cursor.y = 7
|
|
189
|
+
a.store.state.name = 'alice!'
|
|
190
|
+
await flushReactive(a.store.state)
|
|
191
|
+
await waitFor('direct live patch', () => aSeenByB.store.state.cursor.y == 7 && aSeenByB.store.state.name == 'alice!')
|
|
192
|
+
ok(json(aSeenByB.store.state) == json(a.store.snapshot()), 'mirror converged over the direct channel')
|
|
193
|
+
ok(aSeenByB.seq() > seqBefore, `same seq space across the hand-off: ${seqBefore} -> ${aSeenByB.seq()} (no keyframe reset)`)
|
|
194
|
+
ok(bSeenByA.route() == 'relay', 'the other direction is independent and still on relay')
|
|
195
|
+
|
|
196
|
+
// ================= server revoke -> relay fallback =================
|
|
197
|
+
host.revoke('a|b', ['a', 'b'], 'policy change')
|
|
198
|
+
await waitFor('revoke fallback', () => aSeenByB.state() == 'fallback')
|
|
199
|
+
a.store.state.cursor.x = 9
|
|
200
|
+
await flushReactive(a.store.state)
|
|
201
|
+
await waitFor('relay resume after revoke', () => aSeenByB.store.state.cursor.x == 9)
|
|
202
|
+
ok(json(aSeenByB.store.state) == json(a.store.snapshot()), 'server revoke: mirror keeps converging via relay, no gap')
|
|
203
|
+
|
|
204
|
+
// ================= late joiner: relay keyframe without the owner republishing =================
|
|
205
|
+
const connC = await connect('c')
|
|
206
|
+
const c = createPeerClient<World>({
|
|
207
|
+
remote: connC.func.peer, account: 'c',
|
|
208
|
+
initial: {cursor: {x: -1, y: -1}}, drain: 'micro', // relay-only: без rtc
|
|
209
|
+
})
|
|
210
|
+
const aSeenByC = c.peer('a')
|
|
211
|
+
await aSeenByC.ready
|
|
212
|
+
ok(json(aSeenByC.store.state) == json(a.store.snapshot()), 'late joiner gets the folded keyframe from the relay journal')
|
|
213
|
+
let directDenied = false
|
|
214
|
+
try { await c.peer('b').promoteDirect() } catch { directDenied = true }
|
|
215
|
+
ok(directDenied, 'relay-only client (no rtc factory) refuses promoteDirect loudly')
|
|
216
|
+
|
|
217
|
+
// ================= teardown =================
|
|
218
|
+
a.close(); b.close(); c.close()
|
|
219
|
+
connA.closeSocket(); connB.closeSocket(); connC.closeSocket()
|
|
220
|
+
host.close()
|
|
221
|
+
ioServer.close()
|
|
222
|
+
await new Promise<void>(resolve => httpServer.close(() => resolve()))
|
|
223
|
+
|
|
224
|
+
console.log(fails ? `\n${fails} FAILED` : '\nall passed')
|
|
225
|
+
if (fails) process.exit(1)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
main().catch(e => { console.error(e); process.exit(1) })
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {replayListen, withReplayListen, ReplayEvent} from '../src/Common/events/replay-index'
|
|
2
|
+
import {createListen, getListenByOn, isListenOn} from '../src/Common/events/Listen'
|
|
3
|
+
|
|
4
|
+
let fails = 0
|
|
5
|
+
const ok = (condition: any, message: string) => {
|
|
6
|
+
if (!condition) { fails++; console.log(' FAIL', message) }
|
|
7
|
+
else console.log(' OK ', message)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
console.log('\n[replay] live mode unchanged')
|
|
12
|
+
{
|
|
13
|
+
const [emit, listen] = replayListen<[number]>({history: 10})
|
|
14
|
+
const seen: number[] = []
|
|
15
|
+
const off = listen.on(v => seen.push(v))
|
|
16
|
+
emit(1)
|
|
17
|
+
emit(2)
|
|
18
|
+
off()
|
|
19
|
+
emit(3)
|
|
20
|
+
ok(seen.join(',') == '1,2', 'plain on(cb) is live-only, off works')
|
|
21
|
+
ok(listen.head() == 3, 'head counts every journaled emit')
|
|
22
|
+
ok(isListenOn(listen.on) && getListenByOn(listen.on) === listen, 'decorated on is identity-registered')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
console.log('\n[replay] current mode unchanged (layer 3)')
|
|
26
|
+
{
|
|
27
|
+
let state = 0
|
|
28
|
+
const [emit0, listen] = replayListen<[number]>({history: 10, current: () => state ? [state] : undefined})
|
|
29
|
+
const emit = (v: number) => { state = v; emit0(v) }
|
|
30
|
+
emit(7)
|
|
31
|
+
const seen: number[] = []
|
|
32
|
+
listen.on(v => seen.push(v), {current: true})
|
|
33
|
+
emit(8)
|
|
34
|
+
ok(seen.join(',') == '7,8', 'on(cb, {current}) = keyframe + live')
|
|
35
|
+
const once: number[] = []
|
|
36
|
+
const offOnce = listen.once(v => once.push(v), {current: true})
|
|
37
|
+
offOnce()
|
|
38
|
+
ok(once.join(',') == '8' && listen.count() == 1, 'once(current) replays without subscribing')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.log('\n[replay] since catch-up + seamless handover')
|
|
42
|
+
{
|
|
43
|
+
const [emit, listen] = replayListen<[string]>({history: 10})
|
|
44
|
+
emit('a'); emit('b'); emit('c'); emit('d')
|
|
45
|
+
const seen: string[] = []
|
|
46
|
+
const seqs: number[] = []
|
|
47
|
+
listen.on(v => seen.push(v), {since: 2, onSeq: s => seqs.push(s)})
|
|
48
|
+
emit('e')
|
|
49
|
+
ok(seen.join(',') == 'c,d,e', 'replay from seq+1 then live, no gap no dup')
|
|
50
|
+
ok(seqs.join(',') == '3,4,5', 'onSeq reports the seq of every delivered event')
|
|
51
|
+
}
|
|
52
|
+
{
|
|
53
|
+
const [emit, listen] = replayListen<[number]>({history: 10})
|
|
54
|
+
emit(1)
|
|
55
|
+
const seen: number[] = []
|
|
56
|
+
listen.on(v => seen.push(v), {since: 0})
|
|
57
|
+
emit(2)
|
|
58
|
+
ok(seen.join(',') == '1,2', 'since: 0 replays the whole journal')
|
|
59
|
+
}
|
|
60
|
+
{
|
|
61
|
+
const [emit, listen] = replayListen<[number]>({history: 10})
|
|
62
|
+
emit(1); emit(2); emit(3)
|
|
63
|
+
const seen: number[] = []
|
|
64
|
+
listen.on(v => seen.push(v), {since: 3})
|
|
65
|
+
emit(4)
|
|
66
|
+
ok(seen.join(',') == '4', 'since == head: empty replay, live only')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
console.log('\n[replay] re-entrant emit during replay (the subtle handover)')
|
|
70
|
+
{
|
|
71
|
+
const [emit, listen] = replayListen<[number]>({history: 10})
|
|
72
|
+
emit(1); emit(2)
|
|
73
|
+
const seen: number[] = []
|
|
74
|
+
listen.on(function reentrant(v) {
|
|
75
|
+
seen.push(v)
|
|
76
|
+
if (v == 1) emit(100) // emit from inside replay cb
|
|
77
|
+
}, {since: 0})
|
|
78
|
+
emit(3)
|
|
79
|
+
ok(seen.join(',') == '1,2,100,3', 'live emit during replay queues by seq, delivered once, in order')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log('\n[replay] eviction fallback: fresh keyframe + line from it')
|
|
83
|
+
{
|
|
84
|
+
let state = 0
|
|
85
|
+
const [emit0, listen] = replayListen<[number]>({history: 3, current: () => [state]})
|
|
86
|
+
const emit = (v: number) => { state = v; emit0(v) }
|
|
87
|
+
for (let i = 1; i <= 10; i++) emit(i)
|
|
88
|
+
const seen: number[] = []
|
|
89
|
+
const seqs: number[] = []
|
|
90
|
+
listen.on(v => seen.push(v), {since: 2, onSeq: s => seqs.push(s)})
|
|
91
|
+
emit(11)
|
|
92
|
+
ok(seen.join(',') == '10,11', 'evicted seq → keyframe + live, NO backlog queue')
|
|
93
|
+
ok(seqs.join(',') == '10,11', 'keyframe reports head seq → consumer can resubscribe with since')
|
|
94
|
+
}
|
|
95
|
+
{
|
|
96
|
+
let state = 5
|
|
97
|
+
const [emit0, listen] = replayListen<[number]>({history: 3, current: () => [state]})
|
|
98
|
+
const emit = (v: number) => { state = v; emit0(v) }
|
|
99
|
+
const seen: number[] = []
|
|
100
|
+
const seqs: number[] = []
|
|
101
|
+
listen.on(v => seen.push(v), {since: 999, onSeq: s => seqs.push(s)})
|
|
102
|
+
ok(seen.join(',') == '5' && seqs.join(',') == '0', 'since from the future (server restarted) → keyframe fallback')
|
|
103
|
+
emit(6)
|
|
104
|
+
ok(seen.join(',') == '5,6', 'line is reset DOWN: live after the fallback is not muted by the stale big seq')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log('\n[replay] external journal (memory outside, preferred)')
|
|
108
|
+
{
|
|
109
|
+
const journal: ReplayEvent<[number]>[] = []
|
|
110
|
+
const [emit, listen] = replayListen<[number]>({
|
|
111
|
+
onJournal: ev => { journal.push(ev); if (journal.length > 3) journal.shift() },
|
|
112
|
+
getSince: seq => {
|
|
113
|
+
if (journal.length && seq + 1 < journal[0].seq) return undefined
|
|
114
|
+
return journal.filter(ev => ev.seq > seq)
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
for (let i = 1; i <= 5; i++) emit(i)
|
|
118
|
+
const seen: number[] = []
|
|
119
|
+
listen.on(v => seen.push(v), {since: 3})
|
|
120
|
+
emit(6)
|
|
121
|
+
ok(seen.join(',') == '4,5,6', 'external getSince drives replay, decorator owns no data')
|
|
122
|
+
ok(journal.every(ev => typeof ev.ts == 'number'), 'journaled events carry ts attribute')
|
|
123
|
+
ok(journal.map(ev => ev.seq).join(',') == '4,5,6', 'onJournal saw every seq, external ring evicts')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log('\n[replay] journal introspection (for layer B)')
|
|
127
|
+
{
|
|
128
|
+
const [emit, listen] = replayListen<[number]>({history: 5, now: () => 42})
|
|
129
|
+
emit(10); emit(20); emit(30)
|
|
130
|
+
const tail = listen.getSince(1)!
|
|
131
|
+
ok(tail.map(ev => `${ev.seq}:${ev.event[0]}@${ev.ts}`).join(',') == '2:20@42,3:30@42', 'getSince returns {seq, ts, event} tail')
|
|
132
|
+
ok(listen.getSince(3)!.length == 0, 'getSince(head) is empty tail')
|
|
133
|
+
for (let i = 4; i <= 9; i++) emit(i)
|
|
134
|
+
ok(listen.getSince(1) == undefined, 'evicted seq → undefined (caller falls back to keyframe)')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log('\n[replay] decorator over an externally-run base (withReplayListen direct)')
|
|
138
|
+
{
|
|
139
|
+
const base = createListen<[number]>(() => {})
|
|
140
|
+
base.run()
|
|
141
|
+
const listen = withReplayListen(base, {history: 10})
|
|
142
|
+
listen.emit(1)
|
|
143
|
+
listen.emit(2)
|
|
144
|
+
const seen: number[] = []
|
|
145
|
+
listen.on(v => seen.push(v), {since: 1})
|
|
146
|
+
listen.emit(3)
|
|
147
|
+
ok(seen.join(',') == '2,3', 'withReplayListen works over a plain ListenApi')
|
|
148
|
+
base.emit(99) // emitted PAST the decorator - not journaled
|
|
149
|
+
ok(listen.head() == 3 && seen.join(',') == '2,3,99', 'non-journaled emit is delivered live but never journaled')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
console.log(fails ? `\n${fails} FAILED` : '\nall passed')
|
|
153
|
+
if (fails) process.exit(1)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
main()
|