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,314 @@
1
+ // Acceptance oracle for createRouteCoordinator (ROADMAP 0.1 list):
2
+ // policy denial / promotion / failed direct / timeout / re-interposition /
3
+ // shadow relay / revocation / block — over fake in-process connectors.
4
+ import {
5
+ createRouteCoordinator, RouteConnector, RouteConnectorInfo, RoutePairRef, RoutePolicy,
6
+ tConnectorState, tRouteKind,
7
+ } from '../src/Common/events/replay-index'
8
+ import {replayListen} from '../src/Common/events/replay-index'
9
+ import {createStore, applyStorePatch, StorePatch} from '../src/Common/Observe/store'
10
+ import {flushReactive} from '../src/Common/Observe/reactive'
11
+ import {exposeStoreReplay} from '../src/Common/Observe/store-replay'
12
+
13
+ let fails = 0
14
+ const ok = (condition: any, message: string) => {
15
+ if (!condition) { fails++; console.log(' FAIL', message) }
16
+ else console.log(' OK ', message)
17
+ }
18
+ const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
19
+ const json = (v: any) => JSON.stringify(v)
20
+ const ascendingUnique = (seqs: number[]) => seqs.every((s, i) => i == 0 || s > seqs[i - 1])
21
+
22
+ async function waitFor(label: string, cond: () => boolean) {
23
+ for (let i = 0; i < 100; i++) {
24
+ if (cond()) return
25
+ await delay(10)
26
+ }
27
+ throw new Error(`timeout: ${label}`)
28
+ }
29
+
30
+ // =====================================================================
31
+ // Fake in-process network: connectors are pure transports over one replay
32
+ // journal; behavior knobs simulate lag, open failure, catch-up failure.
33
+ // =====================================================================
34
+
35
+ type FakeBehavior = {lag: number, failOpen?: boolean, failCatchUp?: boolean}
36
+
37
+ function makeFakeNet<Z extends any[]>(replay: any) {
38
+ const behavior: Record<tRouteKind, FakeBehavior> = {
39
+ relay: {lag: 5},
40
+ direct: {lag: 25},
41
+ }
42
+ const opened: Record<tRouteKind, number> = {relay: 0, direct: 0}
43
+ const connects: Record<tRouteKind, number> = {relay: 0, direct: 0}
44
+ const conns: Array<{kind: tRouteKind, state: () => tConnectorState, lines: () => number, fail: (reason?: unknown) => void}> = []
45
+
46
+ function connect(ref: RoutePairRef, kind: tRouteKind): RouteConnector<Z> {
47
+ connects[kind]++
48
+ const b = behavior[kind]
49
+ let state: tConnectorState = 'idle'
50
+ let lines = 0
51
+ const failCbs: Array<(reason?: unknown) => void> = []
52
+ const info: RouteConnectorInfo = {label: kind, kind, ordered: true, reliable: true, binary: false}
53
+ const connector: RouteConnector<Z> = {
54
+ info,
55
+ open: async () => {
56
+ state = 'opening'
57
+ await delay(b.lag)
58
+ if (b.failOpen) { state = 'failed'; throw new Error(kind + ' open failed') }
59
+ state = 'open'
60
+ opened[kind]++
61
+ return {
62
+ line: {
63
+ on: (cb: (ev: any) => void) => {
64
+ lines++
65
+ const off = replay.line.on(cb)
66
+ return () => { lines--; off() }
67
+ },
68
+ },
69
+ since: async (s: number) => { await delay(b.lag); if (b.failCatchUp) throw new Error(kind + ' since failed'); return replay.getSince(s) ?? null },
70
+ keyframe: async () => { await delay(b.lag); if (b.failCatchUp) throw new Error(kind + ' keyframe failed'); return replay.keyframe() ?? null },
71
+ frame: async (s: number, hint?: unknown) => { await delay(b.lag); if (b.failCatchUp) throw new Error(kind + ' frame failed'); return replay.frame(s, hint) },
72
+ }
73
+ },
74
+ close: () => { state = 'closed' },
75
+ state: () => state,
76
+ metrics: () => ({rtt: b.lag, pending: 0}),
77
+ onFail: {on: (cb: (reason?: unknown) => void) => { failCbs.push(cb); return () => {} }},
78
+ }
79
+ conns.push({kind, state: connector.state, lines: () => lines, fail: reason => failCbs.forEach(cb => cb(reason))})
80
+ return connector
81
+ }
82
+
83
+ function last(kind: tRouteKind) {
84
+ for (let i = conns.length - 1; i >= 0; i--) if (conns[i].kind == kind) return conns[i]
85
+ throw new Error('no connector of kind ' + kind)
86
+ }
87
+ function activeLines(kind: tRouteKind) {
88
+ return conns.filter(c => c.kind == kind && c.state() == 'open').reduce((n, c) => n + c.lines(), 0)
89
+ }
90
+ return {connect, behavior, opened, connects, last, activeLines}
91
+ }
92
+
93
+ type World = {units: Record<string, {hp: number, x: number}>, tick: number}
94
+
95
+ async function main() {
96
+ console.log('\n[route-coordinator] policy gates: direct is never attempted on denial')
97
+ {
98
+ let state = 0
99
+ const [, replay] = replayListen<[number]>({current: () => [state], history: 100})
100
+ const net = makeFakeNet<[number]>(replay)
101
+ const denials: Array<[RoutePolicy, string]> = [
102
+ [{canDirect: () => false}, 'canDirect'],
103
+ [{canDirect: () => true, mustRelay: () => true}, 'mustRelay'],
104
+ [{canExposeEndpoint: () => false}, 'canExposeEndpoint'],
105
+ ]
106
+ for (const [policy, name] of denials) {
107
+ const coord = createRouteCoordinator<[number]>({connect: net.connect, policy})
108
+ const link = coord.pair('a', 'b')
109
+ const sub = link.subscribe(() => {})
110
+ await sub.ready
111
+ const res = await link.promoteDirect()
112
+ ok(!res.ok && String(res.reason).includes(name), `policy ${name}: promotion denied with reason`)
113
+ ok(link.state() == 'relay', `policy ${name}: state stays relay`)
114
+ coord.close()
115
+ }
116
+ ok(net.connects.direct == 0, 'denied direct is NEVER attempted (no direct connector created)')
117
+ }
118
+
119
+ console.log('\n[route-coordinator] promotion: relay stays live until direct catches up')
120
+ {
121
+ let state = 0
122
+ const [emit, replay] = replayListen<[number]>({current: () => [state], history: 100})
123
+ const net = makeFakeNet<[number]>(replay)
124
+ const coord = createRouteCoordinator<[number]>({connect: net.connect})
125
+ const routeLog: string[] = []
126
+ coord.onRoute(ev => routeLog.push(`${ev.from}->${ev.to}`))
127
+ const link = coord.pair('a', 'b')
128
+ const got: number[] = []
129
+ const seqs: number[] = []
130
+ const sub = link.subscribe(n => got.push(n), {onSeq: s => seqs.push(s)})
131
+ await sub.ready
132
+ ok(json(got) == json([0]) && link.label() == 'relay', 'pair starts on relay with keyframe')
133
+
134
+ state = 1; emit(state)
135
+ state = 2; emit(state)
136
+ await delay(10)
137
+
138
+ const promote = link.promoteDirect()
139
+ await delay(10)
140
+ ok(link.state() == 'direct:connecting', 'promotion passes through direct:connecting')
141
+ ok(net.activeLines('relay') == 1, 'old relay line stays live while direct catches up')
142
+ state = 3; emit(state)
143
+ state = 4; emit(state)
144
+ const res = await promote
145
+ ok(res.ok && res.state == 'direct' && link.state() == 'direct', 'promotion lands in direct')
146
+ ok(net.last('relay').state() == 'closed', 'relay connector is closed after clean direct (relay stepped out)')
147
+ state = 5; emit(state)
148
+ await delay(10)
149
+ ok(json(got) == json([0, 1, 2, 3, 4, 5]), 'promotion is gap-free and dup-free')
150
+ ok(ascendingUnique(seqs), `seqs strictly ascending: ${seqs.join(',')}`)
151
+ ok(routeLog.includes('relay->direct:connecting') && routeLog.includes('direct:connecting->direct'), 'onRoute reports the transitions')
152
+
153
+ // re-interposition: relay steps back into the path, resuming from seq
154
+ const back = await link.reinterposeRelay('audit')
155
+ ok(back.ok && link.state() == 'relay', 're-interposition lands back in relay')
156
+ ok(net.last('direct').state() == 'closed', 'direct connector is closed after re-interposition')
157
+ state = 6; emit(state)
158
+ await delay(10)
159
+ ok(json(got) == json([0, 1, 2, 3, 4, 5, 6]), 're-interposition resumes from seq (gap-free)')
160
+ coord.close()
161
+ ok(link.state() == 'closed', 'coordinator close tears the link down')
162
+ }
163
+
164
+ console.log('\n[route-coordinator] failed direct: relay continues, no data gap')
165
+ {
166
+ let state = 0
167
+ const [emit, replay] = replayListen<[number]>({current: () => [state], history: 100})
168
+ const net = makeFakeNet<[number]>(replay)
169
+ net.behavior.direct.failCatchUp = true
170
+ const coord = createRouteCoordinator<[number]>({connect: net.connect})
171
+ const link = coord.pair('a', 'b')
172
+ const got: number[] = []
173
+ const sub = link.subscribe(n => got.push(n))
174
+ await sub.ready
175
+ state = 1; emit(state)
176
+ const res = await link.promoteDirect()
177
+ ok(!res.ok && link.state() == 'fallback', 'failed direct catch-up marks fallback')
178
+ state = 2; emit(state)
179
+ await delay(10)
180
+ ok(json(got) == json([0, 1, 2]), 'relay keeps flowing with no gap after failed direct')
181
+ ok(sub.label() == 'relay', 'consumer is still riding the relay route')
182
+
183
+ // open() failure is the same story
184
+ net.behavior.direct.failCatchUp = false
185
+ net.behavior.direct.failOpen = true
186
+ const res2 = await link.promoteDirect()
187
+ state = 3; emit(state)
188
+ await delay(10)
189
+ ok(!res2.ok && json(got) == json([0, 1, 2, 3]), 'failed direct open also keeps relay flowing')
190
+ coord.close()
191
+ }
192
+
193
+ console.log('\n[route-coordinator] catch-up timeout: slow replacement fails the switch')
194
+ {
195
+ let state = 0
196
+ const [emit, replay] = replayListen<[number]>({current: () => [state], history: 100})
197
+ const net = makeFakeNet<[number]>(replay)
198
+ net.behavior.direct.lag = 120
199
+ const coord = createRouteCoordinator<[number]>({connect: net.connect, catchUpTimeoutMs: 40})
200
+ const link = coord.pair('a', 'b')
201
+ const got: number[] = []
202
+ const sub = link.subscribe(n => got.push(n))
203
+ await sub.ready
204
+ const res = await link.promoteDirect()
205
+ ok(!res.ok && String(res.reason).includes('timeout') && link.state() == 'fallback', 'slow direct catch-up times out into fallback')
206
+ state = 1; emit(state)
207
+ await delay(200)
208
+ state = 2; emit(state)
209
+ await delay(10)
210
+ ok(json(got) == json([0, 1, 2]), 'no dups/gaps even after the slow direct eventually resolves')
211
+ ok(sub.label() == 'relay', 'consumer settled back on relay')
212
+ coord.close()
213
+ }
214
+
215
+ console.log('\n[route-coordinator] shadow relay: direct data path + relay audit copy')
216
+ {
217
+ let state = 0
218
+ const [emit, replay] = replayListen<[number]>({current: () => [state], history: 100})
219
+ const net = makeFakeNet<[number]>(replay)
220
+ const audit: Array<[string, number]> = []
221
+ const coord = createRouteCoordinator<[number]>({
222
+ connect: net.connect,
223
+ policy: {mustShadowRelay: () => true},
224
+ shadow: (ref, n) => audit.push([ref.key, n]),
225
+ })
226
+ const link = coord.pair('a', 'b')
227
+ const got: number[] = []
228
+ const sub = link.subscribe(n => got.push(n))
229
+ await sub.ready
230
+ const res = await link.promoteDirect()
231
+ ok(res.ok && link.state() == 'direct+shadowRelay', 'promotion with mustShadowRelay lands in direct+shadowRelay')
232
+ ok(sub.label() == 'direct', 'payload path is direct')
233
+ ok(net.last('relay').state() == 'open', 'relay connector stays open for the audit copy')
234
+ state = 1; emit(state)
235
+ state = 2; emit(state)
236
+ await waitFor('audit copy', () => audit.length >= 2)
237
+ ok(audit.some(([, n]) => n == 1) && audit.some(([, n]) => n == 2), 'audit mirror observes the same events')
238
+ ok(audit.every(([key]) => key == 'a|b'), 'audit events carry the pair ref')
239
+ ok(json(got.slice(-2)) == json([1, 2]), 'consumer data flows while audit observes')
240
+
241
+ // revocation: direct dies -> close direct, resume relay from seq
242
+ net.last('direct').fail('endpoint revoked')
243
+ await waitFor('revocation fallback', () => link.state() == 'fallback')
244
+ ok(net.last('direct').state() == 'closed', 'revoked direct connector is closed')
245
+ state = 3; emit(state)
246
+ await delay(10)
247
+ ok(json(got) == json([0, 1, 2, 3]), 'relay resumes from seq after revocation, facade API unchanged')
248
+ ok(sub.label() == 'relay', 'consumer is back on relay after revocation')
249
+ coord.close()
250
+ }
251
+
252
+ console.log('\n[route-coordinator] canReinterpose + block')
253
+ {
254
+ let state = 0
255
+ const [emit, replay] = replayListen<[number]>({current: () => [state], history: 100})
256
+ const net = makeFakeNet<[number]>(replay)
257
+ const coord = createRouteCoordinator<[number]>({connect: net.connect, policy: {canReinterpose: () => false}})
258
+ const link = coord.pair('b', 'a')
259
+ ok(link.ref.key == 'a|b' && coord.pair('a', 'b') == link, 'pair key is symmetric: pair(b,a) == pair(a,b)')
260
+ const got: number[] = []
261
+ const sub = link.subscribe(n => got.push(n))
262
+ await sub.ready
263
+ await link.promoteDirect()
264
+ const denied = await link.reinterposeRelay()
265
+ ok(!denied.ok && link.state() == 'direct', 'canReinterpose=false keeps the direct route')
266
+
267
+ const blocked = await link.block('moderation')
268
+ ok(blocked.ok && link.state() == 'blocked', 'block lands in blocked from any state')
269
+ state = 1; emit(state)
270
+ await delay(10)
271
+ ok(json(got) == json([0]), 'blocked pair delivers nothing')
272
+ ok((await link.promoteDirect()).ok == false, 'blocked pair cannot promote')
273
+ let threw = false
274
+ try { link.subscribe(() => {}) } catch { threw = true }
275
+ ok(threw, 'blocked pair rejects new subscriptions')
276
+ coord.close()
277
+ }
278
+
279
+ console.log('\n[route-coordinator] store mirror rides the coordinator unchanged')
280
+ {
281
+ const backend = createStore<World>({units: {a: {hp: 100, x: 0}}, tick: 0}, {drain: 'micro'})
282
+ const exposed = exposeStoreReplay(backend, {history: 100})
283
+ const net = makeFakeNet<[StorePatch]>(exposed.replay)
284
+ const coord = createRouteCoordinator<[StorePatch]>({connect: net.connect})
285
+ const link = coord.pair('server', 'client')
286
+ const mirror = createStore<World>({units: {}, tick: -1}, {drain: 'micro'})
287
+ const sub = link.subscribe(function applyRoutedPatch(patch) { applyStorePatch(mirror, patch) })
288
+ await sub.ready
289
+ ok(json(mirror.state) == json(backend.snapshot()), 'mirror starts from route keyframe')
290
+
291
+ backend.state.tick = 1
292
+ await flushReactive(backend.state)
293
+ await waitFor('relay patch', () => mirror.state.tick == 1)
294
+
295
+ const promote = link.promoteDirect()
296
+ backend.state.units.b = {hp: 50, x: 5}
297
+ backend.state.tick = 2
298
+ await flushReactive(backend.state)
299
+ await promote
300
+ backend.state.units.a.hp = 80
301
+ backend.state.tick = 3
302
+ await flushReactive(backend.state)
303
+ await waitFor('direct patch', () => mirror.state.tick == 3)
304
+ ok(link.state() == 'direct', 'store link switched to direct')
305
+ ok(json(mirror.state) == json(backend.snapshot()), 'mirror converged across route hand-off (facade unchanged)')
306
+ coord.close()
307
+ exposed.close()
308
+ }
309
+
310
+ console.log(fails ? `\n${fails} FAILED` : '\nall passed')
311
+ if (fails) process.exit(1)
312
+ }
313
+
314
+ main().catch(e => { console.error(e); process.exit(1) })
@@ -0,0 +1,150 @@
1
+ import {replayListen, ReplayRemote, replayRouteSubscribe} from '../src/Common/events/replay-index'
2
+ import {createStore, StorePatch} from '../src/Common/Observe/store'
3
+ import {flushReactive} from '../src/Common/Observe/reactive'
4
+ import {exposeStoreReplay, syncStoreReplayRoute} from '../src/Common/Observe/store-replay'
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
+ const ascendingUnique = (seqs: number[]) => seqs.every((s, i) => i == 0 || s > seqs[i - 1])
14
+
15
+ async function waitFor(label: string, cond: () => boolean) {
16
+ for (let i = 0; i < 80; i++) {
17
+ if (cond()) return
18
+ await delay(10)
19
+ }
20
+ throw new Error(`timeout: ${label}`)
21
+ }
22
+
23
+ function makeRemote<Z extends any[]>(replay: any, label: string, lag = 0, failFrame = false) {
24
+ const counters = {line: 0, since: 0, keyframe: 0, frame: 0}
25
+ let active = 0
26
+ const remote: ReplayRemote<Z> = {
27
+ line: {
28
+ on: (cb: (ev: any) => void) => {
29
+ counters.line++
30
+ active++
31
+ const off = replay.line.on(cb)
32
+ return () => { active--; off() }
33
+ },
34
+ },
35
+ since: async (s: number) => { counters.since++; await delay(lag); return replay.getSince(s) ?? null },
36
+ keyframe: async () => { counters.keyframe++; await delay(lag); return replay.keyframe() ?? null },
37
+ frame: async (s: number, hint?: unknown) => {
38
+ counters.frame++
39
+ await delay(lag)
40
+ if (failFrame) throw new Error(`${label} route failed`)
41
+ return replay.frame(s, hint)
42
+ },
43
+ }
44
+ return {remote, counters, active: () => active, label}
45
+ }
46
+
47
+ type World = {
48
+ units: Record<string, {hp: number, x: number}>
49
+ tick: number
50
+ }
51
+
52
+ async function main() {
53
+ console.log('\n[route-handoff] generic replay relay <-> direct promotion')
54
+ {
55
+ let state = 0
56
+ const [emit, replay] = replayListen<[number]>({current: () => [state], history: 100})
57
+ const relay = makeRemote<[number]>(replay, 'relay', 5)
58
+ const direct = makeRemote<[number]>(replay, 'direct', 35)
59
+ const bad = makeRemote<[number]>(replay, 'bad', 10, true)
60
+ const got: number[] = []
61
+ const seqs: number[] = []
62
+ const phases: string[] = []
63
+ const sub = replayRouteSubscribe<[number]>(relay.remote, n => got.push(n), {
64
+ label: 'relay',
65
+ onSeq: s => seqs.push(s),
66
+ onRoute: ev => phases.push(`${ev.phase}:${ev.from ?? ''}->${ev.to ?? ''}`),
67
+ })
68
+ await sub.ready
69
+ ok(json(got) == json([0]), 'initial route starts with one keyframe')
70
+ ok(sub.label() == 'relay' && relay.active() == 1, 'relay route is active')
71
+
72
+ state = 1; emit(state)
73
+ state = 2; emit(state)
74
+ await delay(5)
75
+
76
+ const promote = sub.switch(direct.remote, {label: 'direct'})
77
+ await delay(5)
78
+ ok(relay.active() == 1 && direct.active() == 1, 'old relay stays alive while direct catches up')
79
+ state = 3; emit(state)
80
+ state = 4; emit(state)
81
+ await promote
82
+ ok(sub.label() == 'direct' && relay.active() == 0 && direct.active() == 1, 'direct becomes active and relay is closed after catch-up')
83
+ state = 5; emit(state)
84
+ await delay(5)
85
+ ok(json(got) == json([0, 1, 2, 3, 4, 5]), 'promotion has no gaps or duplicate deliveries')
86
+ ok(ascendingUnique(seqs), `seqs are strictly ascending after promotion: ${seqs.join(',')}`)
87
+ ok(direct.counters.frame == 1, 'promotion uses frame/since coordinate, not a blind new snapshot')
88
+
89
+ let failed = false
90
+ const failSwitch = sub.switch(bad.remote, {label: 'bad'}).catch(() => { failed = true })
91
+ await delay(2)
92
+ state = 6; emit(state)
93
+ await failSwitch
94
+ state = 7; emit(state)
95
+ await delay(5)
96
+ ok(failed, 'failed replacement route rejects the switch')
97
+ ok(sub.label() == 'direct' && direct.active() == 1 && bad.active() == 0, 'failed switch keeps the old direct route alive')
98
+ ok(json(got) == json([0, 1, 2, 3, 4, 5, 6, 7]), 'events keep flowing on the old route after failed promotion')
99
+
100
+ const reinterpose = sub.switch(relay.remote, {label: 'relay'})
101
+ await delay(5)
102
+ state = 8; emit(state)
103
+ await reinterpose
104
+ state = 9; emit(state)
105
+ await delay(5)
106
+ ok(sub.label() == 'relay' && relay.active() == 1 && direct.active() == 0, 'relay can re-interpose after direct route')
107
+ ok(json(got) == json([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), 're-interposition is also gap-free')
108
+ ok(phases.includes('ready:relay->direct') && phases.includes('ready:direct->relay'), 'route lifecycle reports both hand-offs')
109
+ sub()
110
+ ok(relay.active() == 0 && direct.active() == 0, 'close tears down the active route')
111
+ }
112
+
113
+ console.log('\n[route-handoff] store mirror over a switched replay route')
114
+ {
115
+ const backend = createStore<World>({units: {a: {hp: 100, x: 0}}, tick: 0}, {drain: 'micro'})
116
+ const exposed = exposeStoreReplay(backend, {history: 100})
117
+ const relay = makeRemote<[StorePatch]>(exposed.replay, 'store-relay', 5)
118
+ const direct = makeRemote<[StorePatch]>(exposed.replay, 'store-direct', 30)
119
+ const mirror = createStore<World>({units: {}, tick: -1}, {drain: 'micro'})
120
+ const sub = syncStoreReplayRoute(mirror, relay.remote, {label: 'relay'})
121
+ await sub.ready
122
+ ok(json(mirror.state) == json(backend.snapshot()), 'store mirror starts from route keyframe')
123
+
124
+ backend.state.tick = 1
125
+ await flushReactive(backend.state)
126
+ await waitFor('live relay patch', () => mirror.state.tick == 1)
127
+
128
+ const promote = sub.switch(direct.remote, {label: 'direct'})
129
+ await delay(5)
130
+ backend.state.units.b = {hp: 50, x: 5}
131
+ await flushReactive(backend.state)
132
+ backend.state.tick = 2
133
+ await flushReactive(backend.state)
134
+ await promote
135
+ ok(relay.active() == 0 && direct.active() == 1, 'store route switched to direct')
136
+
137
+ backend.state.units.a.hp = 80
138
+ backend.state.tick = 3
139
+ await flushReactive(backend.state)
140
+ await waitFor('live direct patch', () => mirror.state.tick == 3 && mirror.state.units.a.hp == 80)
141
+ ok(json(mirror.state) == json(backend.snapshot()), 'store mirror remains converged across route hand-off')
142
+ sub()
143
+ exposed.close()
144
+ }
145
+
146
+ console.log(fails ? `\n${fails} FAILED` : '\nall passed')
147
+ if (fails) process.exit(1)
148
+ }
149
+
150
+ main().catch(e => { console.error(e); process.exit(1) })