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,150 @@
1
+ import {listen as createListenPair} from "../../src/Common/events/Listen"
2
+ import {createRpcClient, type RpcClientReturn} from "../../src/Common/rcp/rpc-client"
3
+ import {createRpcServerAuto} from "../../src/Common/rcp/rpc-server-auto"
4
+ import {createRpcServerAutoDetect} from "../../src/Common/rcp/createRpcServerAutoWithProtocolDetection"
5
+ import {listenSocket} from "../../src/Common/rcp/listen-socket"
6
+ import {RPC_STOP, type SocketTmpl} from "../../src/Common/rcp/rpc-protocol"
7
+ import type {DeepSocketListen} from "../../src/Common/rcp/listen-deep"
8
+
9
+ function createLoopback(): [SocketTmpl, SocketTmpl] {
10
+ const A: Record<string, ((d: any) => void)[]> = {}
11
+ const B: Record<string, ((d: any) => void)[]> = {}
12
+ const make = (mine: typeof A, theirs: typeof A): SocketTmpl => ({
13
+ on: (e, cb) => { (mine[e] ??= []).push(cb) },
14
+ emit: (e, d) => {
15
+ const wire = d === undefined ? undefined : JSON.parse(JSON.stringify(d))
16
+ for (const cb of (theirs[e] ?? [])) queueMicrotask(() => cb(wire))
17
+ },
18
+ })
19
+ return [make(A, B), make(B, A)]
20
+ }
21
+
22
+ function webListen<T extends object>(c: RpcClientReturn<T>) {
23
+ return c.func as unknown as DeepSocketListen<T>
24
+ }
25
+
26
+ const delay = (ms = 0) => new Promise<void>(r => setTimeout(r, ms))
27
+
28
+ function same(a: any, b: any): boolean {
29
+ return JSON.stringify(a) === JSON.stringify(b)
30
+ }
31
+
32
+ async function check<T>(name: string, run: () => T | Promise<T>, exp: NoInfer<T>) {
33
+ const got = await run()
34
+ if (!same(got, exp)) throw new Error(`${name}: got=${JSON.stringify(got)} exp=${JSON.stringify(exp)}`)
35
+ console.log(`PASS ${name}`)
36
+ }
37
+
38
+ async function main() {
39
+ {
40
+ const [cs, ss] = createLoopback()
41
+ const [emit1, listen1] = createListenPair<number>()
42
+ const [emit2, secondListen] = createListenPair<number>()
43
+ const obj1 = {stream: listen1}
44
+ const obj2 = {stream: secondListen}
45
+ const c = createRpcClient<typeof obj1>({socket: cs, socketKey: "rpc", dedupeListen: false})
46
+ createRpcServerAuto({socket: ss, object: obj1, socketKey: "rpc"})
47
+ const oldGot: number[] = []
48
+ webListen(c).stream.callback((v) => oldGot.push(v))
49
+ await delay(10)
50
+ await check("createRpcServerAuto initial Listen subscribed", async () => listen1.count(), 1)
51
+
52
+ createRpcServerAuto({socket: ss, object: obj2, socketKey: "rpc"})
53
+ await delay(10)
54
+ await check("createRpcServerAuto reinit removes old Listen", async () => listen1.count(), 0)
55
+ emit1(1)
56
+ await delay(10)
57
+ await check("createRpcServerAuto old Listen is silent after reinit", async () => oldGot, [])
58
+
59
+ const newGot: number[] = []
60
+ ;(webListen(c) as unknown as DeepSocketListen<typeof obj2>).stream.callback((v) => newGot.push(v))
61
+ await delay(10)
62
+ emit2(2)
63
+ await delay(10)
64
+ await check("createRpcServerAuto reinit keeps new server usable", async () => newGot, [2])
65
+ }
66
+
67
+ {
68
+ const [cs, ss] = createLoopback()
69
+ const [emit, listen] = createListenPair<number>()
70
+ const obj = {stream: listen}
71
+ const c = createRpcClient<typeof obj>({socket: cs, socketKey: "rpc", dedupeListen: false})
72
+ const auto = createRpcServerAutoDetect({socket: ss, object: obj, socketKey: "rpc"})
73
+ const got: number[] = []
74
+ webListen(c).stream.callback((v) => got.push(v))
75
+ await delay(10)
76
+ await check("auto2 dispose setup subscribed Listen", async () => listen.count(), 1)
77
+
78
+ auto.dispose("test")
79
+ await delay(10)
80
+ await check("auto2.dispose removes Listen", async () => listen.count(), 0)
81
+ emit(1)
82
+ await delay(10)
83
+ await check("auto2.dispose leaves no live stream", async () => got, [])
84
+ }
85
+
86
+ {
87
+ const [cs, ss] = createLoopback()
88
+ const [emit, listen] = createListenPair<number>()
89
+ const obj = {stream: listen}
90
+ const c1 = createRpcClient<typeof obj>({socket: cs, socketKey: "rpc", dedupeListen: false})
91
+ const auto = createRpcServerAutoDetect({socket: ss, object: obj, socketKey: "rpc"})
92
+ const oldGot: number[] = []
93
+ webListen(c1).stream.callback((v) => oldGot.push(v))
94
+ await delay(10)
95
+ await check("auto2 reset setup subscribed Listen", async () => listen.count(), 1)
96
+
97
+ auto.reset()
98
+ await delay(10)
99
+ await check("auto2.reset removes current Listen", async () => listen.count(), 0)
100
+ emit(10)
101
+ await delay(10)
102
+ await check("auto2.reset leaves no dangling subscription", async () => oldGot, [])
103
+
104
+ const c2 = createRpcClient<typeof obj>({socket: cs, socketKey: "rpc", dedupeListen: false})
105
+ const newGot: number[] = []
106
+ webListen(c2).stream.callback((v) => newGot.push(v))
107
+ await delay(10)
108
+ await check("auto2.reset allows one fresh subscription", async () => listen.count(), 1)
109
+ emit(11)
110
+ await delay(10)
111
+ await check("auto2.reset fresh subscription receives once", async () => newGot, [11])
112
+ }
113
+
114
+ {
115
+ const [emit, listen] = createListenPair<number>()
116
+ const socket = listenSocket(listen)
117
+ let calls = 0
118
+ const sub = socket.once(() => {
119
+ calls++
120
+ throw new Error("user callback failed")
121
+ })
122
+
123
+ let thrown = ""
124
+ try { emit(1) }
125
+ catch (e: any) { thrown = e?.message ?? String(e) }
126
+
127
+ await check("listenSocket.once propagates user throw", async () => thrown, "user callback failed")
128
+ await check("listenSocket.once finally removes listener after throw", async () => listen.count(), 0)
129
+ await check("listenSocket.once handle settles after throw cleanup", () => Promise.race([sub.then(() => "settled"), delay(60).then(() => "hung")]), "settled")
130
+ emit(2)
131
+ await check("listenSocket.once throwing callback is not called again", async () => calls, 1)
132
+ }
133
+
134
+ {
135
+ const [emit, listen] = createListenPair<any>()
136
+ const socket = listenSocket(listen)
137
+ const got: any[] = []
138
+ const sub = socket.once((v) => got.push(v))
139
+ emit(RPC_STOP)
140
+ await delay(0)
141
+ await check("RPC_STOP does not reach listenSocket.once user callback", async () => got, [])
142
+ await check("RPC_STOP cleans listenSocket.once subscription", async () => listen.count(), 0)
143
+ await check("RPC_STOP settles listenSocket.once handle", () => Promise.race([sub.then(() => "settled"), delay(60).then(() => "hung")]), "settled")
144
+ }
145
+ }
146
+
147
+ main().catch(e => {
148
+ console.error(e?.stack ?? e)
149
+ process.exitCode = 1
150
+ })
@@ -0,0 +1,209 @@
1
+ // Oracle: store.each() (per-changed-key listen) + syncStoreReplayEach (one-call remote fold).
2
+ // Prompt: doc/target/wenay-common2-store-ergonomics-prompt.md. Disposable oracle, ts-node --transpile-only.
3
+
4
+ import {createStore} from '../../src/Common/Observe/store'
5
+ import {flushReactive} from '../../src/Common/Observe/reactive'
6
+ import {exposeStoreReplay, syncStoreReplayEach} from '../../src/Common/Observe/store-replay'
7
+
8
+ let failures = 0
9
+ const tests: Array<{name: string; run: () => void | Promise<void>}> = []
10
+
11
+ function test(name: string, run: () => void | Promise<void>) {
12
+ tests.push({name, run})
13
+ }
14
+
15
+ function assert(cond: unknown, message: string) {
16
+ if (!cond) throw new Error(message)
17
+ }
18
+
19
+ function assertDeepEq(actual: unknown, expected: unknown, message: string) {
20
+ const a = JSON.stringify(actual)
21
+ const e = JSON.stringify(expected)
22
+ if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`)
23
+ }
24
+
25
+ const tick = () => new Promise<void>(resolve => setTimeout(resolve, 0))
26
+
27
+ type tCall = [string, any]
28
+ function collect(calls: tCall[]) {
29
+ return function onEachKey(key: string, value: any) {
30
+ calls.push([key, value === undefined ? undefined : JSON.parse(JSON.stringify(value))])
31
+ }
32
+ }
33
+
34
+ // ============================================================
35
+ // each()
36
+ // ============================================================
37
+
38
+ test('each: N keys written in one window -> N calls with fresh values', async () => {
39
+ const store = createStore<Record<string, {hp: number}>>({}, {drain: 'micro'})
40
+ const calls: tCall[] = []
41
+ const off = store.each().on(collect(calls))
42
+ store.state.a = {hp: 1}
43
+ store.state.b = {hp: 2}
44
+ store.state.c = {hp: 3}
45
+ await flushReactive(store.state); await tick()
46
+ off()
47
+ calls.sort()
48
+ assertDeepEq(calls, [['a', {hp: 1}], ['b', {hp: 2}], ['c', {hp: 3}]], 'one call per changed key')
49
+ })
50
+
51
+ test('each: two writes to one key in a window -> ONE call with the last value', async () => {
52
+ const store = createStore<Record<string, number>>({}, {drain: 'micro'})
53
+ const calls: tCall[] = []
54
+ const off = store.each().on(collect(calls))
55
+ store.state.x = 1
56
+ store.state.x = 2
57
+ await flushReactive(store.state); await tick()
58
+ off()
59
+ assertDeepEq(calls, [['x', 2]], 'coalesced to the last value')
60
+ })
61
+
62
+ test('each: deleted key -> (key, undefined)', async () => {
63
+ const store = createStore<Record<string, number>>({x: 1, y: 2}, {drain: 'micro'})
64
+ const calls: tCall[] = []
65
+ const off = store.each().on(collect(calls))
66
+ delete store.state.x
67
+ await flushReactive(store.state); await tick()
68
+ off()
69
+ assertDeepEq(calls, [['x', undefined]], 'delete reports undefined')
70
+ })
71
+
72
+ test('each: deeper dirt reports the top-level key once (coalesced)', async () => {
73
+ const store = createStore<any>({a: {b: {c: 1}, d: 1}}, {drain: 'micro'})
74
+ const calls: tCall[] = []
75
+ const off = store.each().on(collect(calls))
76
+ store.state.a.b.c = 2
77
+ store.state.a.d = 5
78
+ await flushReactive(store.state); await tick()
79
+ off()
80
+ assertDeepEq(calls, [['a', {b: {c: 2}, d: 5}]], 'one call for the top key with the whole branch')
81
+ })
82
+
83
+ test('each: root replace expands per key of the new state + undefined for gone keys', async () => {
84
+ const store = createStore<Record<string, {v: number}>>({a: {v: 1}, b: {v: 2}}, {drain: 'micro'})
85
+ const calls: tCall[] = []
86
+ const off = store.each().on(collect(calls))
87
+ store.replace({b: {v: 20}, c: {v: 30}})
88
+ await flushReactive(store.state); await tick()
89
+ off()
90
+ calls.sort()
91
+ assertDeepEq(calls, [['a', undefined], ['b', {v: 20}], ['c', {v: 30}]],
92
+ 'replace = per-key calls for the new state + (key, undefined) for removed keys')
93
+ })
94
+
95
+ test('each: cold store -> zero cost (no listenPaths subscription until first listener)', () => {
96
+ const store = createStore<Record<string, number>>({x: 1})
97
+ const each = store.each()
98
+ assert(!each.isRunning(), 'no upstream subscription before the first listener')
99
+ const off = each.on(() => {})
100
+ assert(each.isRunning(), 'upstream subscription appears with the first listener')
101
+ off()
102
+ assert(!each.isRunning(), 'upstream subscription is torn down with the last listener')
103
+ })
104
+
105
+ test('each: depth is reserved — only 1 accepted', () => {
106
+ const store = createStore<Record<string, number>>({})
107
+ store.each({depth: 1})
108
+ let threw = false
109
+ try { store.each({depth: 2}) } catch { threw = true }
110
+ assert(threw, 'depth != 1 must throw (reserved)')
111
+ })
112
+
113
+ // ============================================================
114
+ // syncStoreReplayEach (in-proc replay wire)
115
+ // ============================================================
116
+
117
+ test('syncStoreReplayEach: keyframe start expands per key; live per-key; delete -> undefined', async () => {
118
+ const world = createStore<Record<string, {hp: number}>>({a: {hp: 100}, b: {hp: 50}}, {drain: 'micro'})
119
+ const exposed = exposeStoreReplay(world, {history: 64})
120
+ const calls: tCall[] = []
121
+ const sub = syncStoreReplayEach<Record<string, {hp: number}>>(exposed.api.replay, collect(calls), {drain: 'micro'})
122
+ await sub.ready
123
+ await tick()
124
+ const cold = [...calls].sort()
125
+ assertDeepEq(cold, [['a', {hp: 100}], ['b', {hp: 50}]], 'keyframe expands per key')
126
+
127
+ calls.length = 0
128
+ world.state.a = {hp: 42}
129
+ await flushReactive(world.state); await tick(); await tick()
130
+ assertDeepEq(calls, [['a', {hp: 42}]], 'live delta arrives per key')
131
+
132
+ calls.length = 0
133
+ delete world.state.b
134
+ await flushReactive(world.state); await tick(); await tick()
135
+ assertDeepEq(calls, [['b', undefined]], 'remote delete reports undefined')
136
+
137
+ assert(sub.store.state.a.hp == 42, 'mirror store readable directly via off.store')
138
+ assert(sub.seq() >= 0, 'seq() reports the reconnect point')
139
+ sub()
140
+ exposed.close()
141
+ })
142
+
143
+ test('syncStoreReplayEach: reconnect with {since} delivers only changed keys', async () => {
144
+ const world = createStore<Record<string, {hp: number}>>({a: {hp: 1}, b: {hp: 2}}, {drain: 'micro'})
145
+ const exposed = exposeStoreReplay(world, {history: 64})
146
+ let lastSeq = -1
147
+ const first = syncStoreReplayEach<Record<string, {hp: number}>>(exposed.api.replay, () => {},
148
+ {drain: 'micro', onSeq: s => lastSeq = s})
149
+ await first.ready; await tick()
150
+ first()
151
+
152
+ // мир живёт, пока клиент оффлайн — реконнект должен доехать ХВОСТОМ (только b)
153
+ world.state.b = {hp: 22}
154
+ await flushReactive(world.state); await tick()
155
+
156
+ const calls: tCall[] = []
157
+ // реконнект = прежнее состояние (initial) + {since}: хвост доезжает поверх него
158
+ const second = syncStoreReplayEach<Record<string, {hp: number}>>(exposed.api.replay, collect(calls),
159
+ {drain: 'micro', since: lastSeq, initial: first.store.snapshot()})
160
+ await second.ready
161
+ await tick()
162
+ assertDeepEq(calls, [['b', {hp: 22}]], 'tail reconnect delivers only the changed key')
163
+ assertDeepEq(second.store.snapshot(), world.snapshot(), 'mirror converged')
164
+ second()
165
+ exposed.close()
166
+ })
167
+
168
+ test('syncStoreReplayEach: off() tears down BOTH the store subscription and the wire sub', async () => {
169
+ const world = createStore<Record<string, number>>({x: 1}, {drain: 'micro'})
170
+ const exposed = exposeStoreReplay(world, {history: 64})
171
+ const lineCount = () => exposed.replay.line.count()
172
+ const before = lineCount()
173
+ const calls: tCall[] = []
174
+ const sub = syncStoreReplayEach<Record<string, number>>(exposed.api.replay, collect(calls), {drain: 'micro'})
175
+ await sub.ready; await tick()
176
+ assert(lineCount() > before, 'wire sub is live while subscribed')
177
+ calls.length = 0
178
+ sub()
179
+ assert(lineCount() == before, 'wire sub removed by off()')
180
+ assert(sub.store.count() == 0, 'no store subscriptions left after off()')
181
+ world.state.x = 99
182
+ await flushReactive(world.state); await tick(); await tick()
183
+ assertDeepEq(calls, [], 'no calls after off()')
184
+ exposed.close()
185
+ })
186
+
187
+ // ============================================================
188
+ // runner
189
+ // ============================================================
190
+
191
+ async function main() {
192
+ for (const t of tests) {
193
+ try {
194
+ await t.run()
195
+ console.log(`OK ${t.name}`)
196
+ } catch (e: any) {
197
+ failures++
198
+ console.error(`FAIL ${t.name}`)
199
+ console.error(e?.stack ?? e)
200
+ }
201
+ }
202
+ console.log(failures === 0 ? `ALL GREEN (${tests.length})` : `${failures} FAILURE(S) / ${tests.length}`)
203
+ process.exit(failures === 0 ? 0 : 1)
204
+ }
205
+
206
+ main().catch(e => {
207
+ console.error(e?.stack ?? e)
208
+ process.exit(1)
209
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wenay-common2",
3
- "version": "1.0.69",
3
+ "version": "1.0.73",
4
4
  "description": "Common library",
5
5
  "strict": true,
6
6
  "main": "lib/index.js",
@@ -8,10 +8,15 @@
8
8
  "files": [
9
9
  "lib/**/*",
10
10
  "doc/**/*",
11
+ "replay/**/*",
12
+ "observe/**/*",
13
+ "oracle/**/*",
14
+ "demo/**/*",
11
15
  "CLAUDE.md",
12
16
  "rpc.md",
13
17
  "!doc/target/**/*",
14
18
  "!doc/progress/**/*",
19
+ "!demo/public/**/*",
15
20
  "!**/*.tsbuildinfo"
16
21
  ],
17
22
  "author": "wenay",
package/replay/PLAN.md ADDED
@@ -0,0 +1,171 @@
1
+ # replay/ — oracles for the Snapshot + Sequenced Delta Line (REPLAY-PLAN.md)
2
+
3
+ ASSEMBLED into `src/` (2026-07-05): this directory now holds only the oracles/demos,
4
+ importing the canonical modules (same pattern as `observe/`). Canonical code:
5
+
6
+ - `src/Common/events/replay-listen.ts` — layer A (`withReplayListen`/`replayListen`)
7
+ - `src/Common/events/replay-wire.ts` — wire pair (`exposeReplay` ⇄ `replaySubscribe`)
8
+ - `src/Common/events/replay-route.ts` — route hand-off helper (`replayRouteSubscribe`)
9
+ - `src/Common/events/replay-conflate.ts` — `conflateReplay` (layer D.1)
10
+ - `src/Common/events/replay-history.ts` — `ReplayStorage`/`createMemoryReplayStorage`/
11
+ `archiveReplay`/`openHistory` (layer C)
12
+ - `src/Common/events/replay-index.ts` — barrel; root export `Replay`, subpath
13
+ `wenay-common2/replay`
14
+ - `src/Common/Observe/store-replay.ts` — layer B (`exposeStoreReplay`/`syncStoreReplay`/
15
+ `storeReplayAt`), in the `Observe` namespace/subpath
16
+ - `src/Common/events/Listen.ts` — canonical Listen import surface; the implementation has
17
+ the additive exports (`registerListenOn`, `ListenOnBrand`) used by replay
18
+ - RPC harness (`rpc.harness.spec.ts`) — cookbook block: replay line over the loopback
19
+ (keyframe sync, live patches, reconnect via since = tail, not a snapshot)
20
+
21
+ ## Files (oracles)
22
+
23
+ - `replay-listen.test.ts` — oracle for layer A (22 checks).
24
+ - `store-replay.test.ts` — oracle for layer B in-proc with simulated wire lag (14 checks).
25
+ - `socket-replay.test.ts` — layer B over a REAL Socket.IO websocket + RPC (11 checks).
26
+ - `route-handoff.test.ts` — transport-agnostic relay ↔ direct route switch over replay,
27
+ failed replacement fallback, and store mirror hand-off (17 checks).
28
+ - `video-socket.demo.ts` — "video" over the line: bouncing-ball frames as store patches,
29
+ late viewer gets keyframe not backlog, lagging viewer catches up via delta tail (7 checks).
30
+ - `canvas-socket.test.ts` — RAW BYTES over the line: Uint8Array RGBA dirty-rects +
31
+ full-frame keyframes over real Socket.IO, byte-for-byte checks (8 checks). Exercises
32
+ the binary passthrough in `src/.../rpc-walk.ts`.
33
+ - `conflate.ts` — layer D.1 (stage 3): `conflateReplay` — per-client conflation gate
34
+ + snapshot recovery, built per connection over `exposeReplay`.
35
+ - `conflate.test.ts` — oracle for the gate: generic line, per-client independence,
36
+ store mirror over a lagged wire (26 checks).
37
+ - `coalesce.test.ts` — oracle for key-level coalescing (`keyOf` in `conflateReplay`):
38
+ last-per-key tail instead of a full keyframe, degradation on unkeyable events /
39
+ maxKeys overflow, store mirror with `storePatchKey` incl. ancestor/descendant
40
+ path interleave and reconnect via since (30 checks).
41
+ - `conflate-socket.test.ts` — per-connection gate over a real Socket.IO wire (9 checks).
42
+ - `history.ts` — layer C (stage 4): `ReplayStorage` lambda interface + memory reference
43
+ impl, `archiveReplay` (event log + keyframe cadence), `openHistory` (seek by seq/ts,
44
+ playback with archive → live-journal → live handover).
45
+ - `history.test.ts` — oracle for layer C: cadence, seek, holes, double handover,
46
+ rewind-to-ts-then-live, store time machine, file(jsonl)-backed storage (27 checks).
47
+
48
+ Run: `npx ts-node replay/<file>.ts` — all green as of 2026-07-05 (against `src/` copies).
49
+
50
+ ## Status
51
+
52
+ - [x] Stage 1 — `withReplayListen` decorator + seq handover (layer A)
53
+ - [x] Stage 1.5 — wire pair (`exposeReplay`/`replaySubscribe`): works over the EXISTING
54
+ RPC untouched — envelope line is a plain Listen, since/keyframe are plain methods
55
+ - [x] Stage 2 — store integration (`exposeStoreReplay`/`syncStoreReplay`) + real-socket tests
56
+ - [x] Route hand-off helper — `replayRouteSubscribe` / `syncStoreReplayRoute`: old route
57
+ stays live, replacement route catches up from `seq`, then the old route closes; overlap is
58
+ deduped by `seq`, failed replacement leaves the old route active.
59
+ - [x] Binary passthrough (layer D item 4, done in `src/` directly — additive):
60
+ `rpc-walk.walk()` treats TypedArray/DataView/Buffer/ArrayBuffer as leaves (checked
61
+ BEFORE `Object.keys` — keys of a big buffer are millions of strings), socket.io carries
62
+ them natively; new `maxBinaryLen` limit (default 8MB). RPC harness green.
63
+ - [x] Stage 3 — per-client conflation + snapshot recovery (`conflateReplay`): outgoing
64
+ buffer over highWater → deltas STOP for that client (dropped, not queued); drained →
65
+ fresh keyframe on the same line + deltas resume from its seq. Client code unchanged —
66
+ seq dedup already handles the overlap.
67
+ - [x] Stage 4 — history storage (layer C): storage = 4 lambdas (`putEvent`/`putKeyframe`/
68
+ `getKeyframe`/`getEvents`), archiver with GOP cadence (every N events OR T ms, whichever
69
+ first, frames only ON events — quiet line changes nothing), reader with seek by seq/ts
70
+ and playback that hands over archive → live journal → live.
71
+ - [x] Key-level coalescing (layer D): `keyOf` + `maxKeys` opts on `conflateReplay` —
72
+ while a client lags, a map key → LAST envelope is kept instead of a pure drop; on
73
+ drain the tail of last-per-key envelopes (ascending seq) is flushed instead of a
74
+ full keyframe. Unkeyable event (keyOf → null) or over maxKeys → the episode degrades
75
+ to classic keyframe recovery. `storePatchKey` = ready keyOf for store patch lines.
76
+ - [ ] Rest of layer D (optimizations, on demand): serialize-once fan-out (pack an envelope
77
+ once, send bytes to all subscribers).
78
+ - [x] Assembly — canonical copies in `src/` (Listen additions additive; store-replay beside
79
+ store.ts), exported as `Replay` namespace + `wenay-common2/replay` subpath and via
80
+ `Observe`; oracles rewired to import `src/`, duplicated impls deleted; replay
81
+ cookbook block added to the RPC harness. Everything is ADDITIVE — nothing existing
82
+ was changed or replaced (Listen/exposeStore/mirror behavior untouched).
83
+
84
+ ## Design decisions (implemented)
85
+
86
+ - **Journal entry** = `{seq, ts, event}`; seq is the coordinate (monotonic from 1), ts an
87
+ attribute (injectable `now`). Only the decorated `func` numbers events; emits past the
88
+ decorator are delivered live but never journaled (tested).
89
+ - **Memory external or internal**: `history: N` = internal ring (O(1) write); `getSince(seq)`
90
+ + `onJournal(ev)` = external journal lambdas (priority). `undefined` from getSince = evicted.
91
+ - **Sync handover** (on(cb, {since})): subscribe live first (liveTap reads the in-flight
92
+ seq from a save/restored `emitting` slot), replay tail, drain re-entrancy queue; dedup by
93
+ strict `seq > lastDelivered`. No gap, no dup.
94
+ - **Async (wire) handover** (`replaySubscribe`): same queue+dedup, but tail/keyframe are
95
+ awaited. Needs an ORDERED transport (socket.io/TCP, in-proc): the line subscription is
96
+ established server-side before `since()` executes → no gap; overlap is killed by seq dedup.
97
+ - **Fallback = killer property**: evicted seq or seq from the future (server restarted) →
98
+ fresh keyframe + live from head. `lastDelivered` resets DOWN too — a stale big seq must
99
+ not mute the new life's events (bug found by test, fixed in both sync and async paths).
100
+ - **Keyframe is an event of the same type**. For stores: a root patch
101
+ `{path: [], exists: true, value: snapshot}` — the mirror applies ONE mechanism
102
+ (`applyStorePatch`) for snapshot and deltas alike.
103
+ - **RPC core untouched**: `on(cb, opts)` opts do NOT travel over the existing wire
104
+ (listen-socket passes only the callback), so the wire surface is `{line, since, keyframe}` —
105
+ a plain Listen + two plain methods. Nothing to change in rpc-*.
106
+ - **exposeStoreReplay journaling is HOT** (subscribes listenPaths immediately): the journal
107
+ must see every change even with zero subscribers, or the line has holes.
108
+ - `replaySubscribe` returns `off` with `.ready` (catch-up finished) and `.seq()` (reconnect
109
+ point). Reconnect = call again with `{since: prev.seq()}`.
110
+ - **Conflation is a per-connection decorator** (`conflateReplay`), transport-agnostic:
111
+ buffer fullness is a `pending()` lambda (for socket.io — e.g. `socket.conn.writeBuffer.length`),
112
+ units are whatever pending/thresholds agree on. Built where the per-connection RPC server
113
+ is built; `api` spreads in place of `exposeReplay(...)`; `close()` on disconnect.
114
+ - **Recovery keyframe rides the SAME line envelope** — the client cannot tell conflation
115
+ from normal flow; `kf.seq == head` and strict seq dedup cut the overlap. Recovery fires
116
+ on the next line event (journal is written before fan-out, so the keyframe already
117
+ covers it) or, on a quiet line, via a `pollMs` interval.
118
+ - Deltas over highWater are **dropped, never queued** (killer property server-side).
119
+ `hasKeyframe` (additive flag on the replay api) guards construction: a gate without a
120
+ current provider would silently lose data — it throws instead.
121
+ - **Key-level coalescing correctness**: events must be ABSOLUTE per key (fully determine
122
+ the key's state — store patches are). The episode map refreshes insertion order on
123
+ update (delete+set), so iteration order = last-touch order = ascending seq; the flushed
124
+ tail is a subsequence of the line where every omitted envelope is masked by a later
125
+ one with the same key — this covers ancestor/descendant path overlap too (an ancestor
126
+ patch carries its whole subtree). Client code unchanged: original seqs, strict dedup,
127
+ gaps are normal. Memory is bounded by distinct keys (maxKeys), not events; the
128
+ current-provider requirement stays because degradation needs a keyframe.
129
+
130
+ ## Measured wire behavior (socket tests)
131
+
132
+ - Fresh client: exactly 1 keyframe request, 0 tail requests, 0 `get()` pulls (pure push).
133
+ - Short lag: tail of exactly the missed patches, NO snapshot.
134
+ - Long offline past the ring: 28 missed patches collapse into 1 keyframe — no backlog, ever.
135
+ - Stalled client (conflation): 22 patches dropped server-side, the wire carried exactly
136
+ 1 envelope (the recovery keyframe) once the buffer drained; other clients unaffected.
137
+ - Stalled client with `keyOf: storePatchKey`: 20 patches of 2 paths collapsed into
138
+ 2 delta envelopes — the big store snapshot never travelled at all.
139
+
140
+ ## Layer C design decisions (implemented)
141
+
142
+ - **Storage is 4 lambdas** (`ReplayStorage`: `putEvent`/`putKeyframe`/`getKeyframe`/`getEvents`),
143
+ the archiver/reader own no data. Memory reference impl doubles as the oracle for
144
+ file/DB impls (the test proves a naive jsonl file storage behaves identically).
145
+ Contracts: `getKeyframe` = nearest ≤ seq/ts (no arg — latest), `getEvents` = (from, to]
146
+ ordered by seq.
147
+ - **Keyframes are written only ON events**: with no events the state didn't change, a
148
+ frame would add nothing → the archiver has no timers at all. Cadence = every N events
149
+ OR T ms of line-ts, whichever first; plus one base frame at attach.
150
+ - **Reader = the same subscriber interface**: `openHistory(storage, live?).subscribe(cb,
151
+ {since|ts, onSeq})` — archive part first, then `live.on(cb, {since: last})`: the
152
+ existing sync handover closes the archive→now gap from the live journal. Hole in the
153
+ archive → fresh start from the latest keyframe; reset DOWN is allowed (redundant but
154
+ consistent deliveries beat a state hole). Without `live` it's pure playback.
155
+ - **Seamless rewind→live** needs the live journal to BE the archive: create the line with
156
+ `getSince: s => storage.getEvents(s, Infinity)` («memory outside» — the option existing
157
+ since stage 1). Otherwise the archive→live gap closes with a keyframe jump — still
158
+ correct, just not continuous.
159
+ - **Store time machine** = `storeReplayAt(storage, {seq|ts})`: scratch store + the same
160
+ `applyStorePatch` for keyframe and deltas; bit-exact even when the live ring is tiny.
161
+ - Live store is never reset — archive = labeled copies on the side.
162
+
163
+ ## Notes for later stages
164
+ - **Binary frames**: DONE — rpc-walk passes TypedArray/ArrayBuffer through as leaves
165
+ (see canvas-socket.test.ts: 12kb raw vs ~97kb mangled JSON per frame). A REAL codec
166
+ stream (H.264) differs only in that an I-frame is NOT computable on demand — `current`
167
+ must return the last archived I-frame + tail from its seq (layer C lambdas).
168
+ - **Conflation**: DONE — see design decisions above. **Key-level coalescing**: DONE —
169
+ `keyOf`/`maxKeys` opts on the same gate (see design decisions). Remaining D item:
170
+ serialize-once fan-out (pack an envelope once, send bytes to all subscribers) — a
171
+ further decorator on the same line, nothing in the core changes.