wenay-common2 1.0.69 → 1.0.70
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 +10 -1
- package/demo/client.ts +100 -0
- package/demo/index.html +28 -0
- package/demo/server.ts +43 -0
- package/doc/changes/1.0.70.md +4 -0
- 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/offline-store-socket.test.ts +290 -0
- package/replay/offline-store.test.ts +156 -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
|
@@ -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.
|
|
3
|
+
"version": "1.0.70",
|
|
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.
|