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.
Files changed (67) hide show
  1. package/README.md +10 -1
  2. package/demo/client.ts +100 -0
  3. package/demo/index.html +28 -0
  4. package/demo/server.ts +43 -0
  5. package/doc/changes/1.0.70.md +4 -0
  6. package/observe/PLAN.md +131 -0
  7. package/observe/README.md +226 -0
  8. package/observe/hot-write.test.ts +95 -0
  9. package/observe/listen-core.test.ts +66 -0
  10. package/observe/listen-store.test.ts +56 -0
  11. package/observe/listen.test.ts +92 -0
  12. package/observe/reactive.test.ts +270 -0
  13. package/observe/reactive.ts +1 -0
  14. package/observe/store-manager.test.ts +118 -0
  15. package/observe/store-mirror.example.ts +74 -0
  16. package/observe/store.test.ts +235 -0
  17. package/observe/store.ts +1 -0
  18. package/observe/usage-real-socket.ts +174 -0
  19. package/observe/usage.ts +200 -0
  20. package/oracle/PASSED.md +27 -0
  21. package/oracle/README.md +12 -0
  22. package/oracle/fixes-primitives.spec.ts +90 -0
  23. package/oracle/realsocket/_rs.ts +106 -0
  24. package/oracle/realsocket/auth.spec.ts +91 -0
  25. package/oracle/realsocket/callbacks.spec.ts +122 -0
  26. package/oracle/realsocket/caps.spec.ts +124 -0
  27. package/oracle/realsocket/core.spec.ts +49 -0
  28. package/oracle/realsocket/dedupe.spec.ts +142 -0
  29. package/oracle/realsocket/errors.spec.ts +121 -0
  30. package/oracle/realsocket/lifecycle.spec.ts +128 -0
  31. package/oracle/realsocket/limits.spec.ts +98 -0
  32. package/oracle/realsocket/pipe.spec.ts +101 -0
  33. package/oracle/realsocket/shape.spec.ts +124 -0
  34. package/oracle/realsocket/slimv2.spec.ts +116 -0
  35. package/oracle/realsocket/stress.spec.ts +132 -0
  36. package/oracle/regression/_clientapiall-replay.fixture.ts +57 -0
  37. package/oracle/regression/async-queues.spec.ts +254 -0
  38. package/oracle/regression/bytestream.spec.ts +152 -0
  39. package/oracle/regression/clientapiall-replay-types.spec.ts +47 -0
  40. package/oracle/regression/core-clone-equal.spec.ts +124 -0
  41. package/oracle/regression/data-structures.spec.ts +135 -0
  42. package/oracle/regression/listen-events.spec.ts +206 -0
  43. package/oracle/regression/observe-core.spec.ts +278 -0
  44. package/oracle/regression/package-export.spec.ts +120 -0
  45. package/oracle/regression/rpc-dedupe-callbacks.spec.ts +195 -0
  46. package/oracle/regression/rpc-lifecycle.spec.ts +150 -0
  47. package/oracle/regression/store-each.spec.ts +209 -0
  48. package/package.json +6 -1
  49. package/replay/PLAN.md +171 -0
  50. package/replay/canvas-socket.test.ts +187 -0
  51. package/replay/coalesce.test.ts +260 -0
  52. package/replay/conflate-socket.test.ts +288 -0
  53. package/replay/conflate.test.ts +225 -0
  54. package/replay/history.test.ts +222 -0
  55. package/replay/media-socket.test.ts +157 -0
  56. package/replay/offline-store-socket.test.ts +290 -0
  57. package/replay/offline-store.test.ts +156 -0
  58. package/replay/peer-sdk.test.ts +228 -0
  59. package/replay/replay-listen.test.ts +156 -0
  60. package/replay/route-coordinator.test.ts +314 -0
  61. package/replay/route-handoff.test.ts +150 -0
  62. package/replay/route-webrtc.test.ts +321 -0
  63. package/replay/rpc-auto.test.ts +256 -0
  64. package/replay/socket-replay.test.ts +199 -0
  65. package/replay/staleness.test.ts +176 -0
  66. package/replay/store-replay.test.ts +151 -0
  67. package/replay/video-socket.demo.ts +200 -0
package/README.md CHANGED
@@ -8,4 +8,13 @@
8
8
  - Extended API cheat sheet: [`doc/wenay-common2-rare.md`](doc/wenay-common2-rare.md)
9
9
  - Naming migrations: [`doc/NAMING_RENAMES.md`](doc/NAMING_RENAMES.md)
10
10
  - Recent changes: [`doc/changes/`](doc/changes/)
11
- - Project rules for AI/code maintenance: [`CLAUDE.md`](CLAUDE.md)
11
+ - Project rules for AI/code maintenance: [`CLAUDE.md`](CLAUDE.md)
12
+
13
+ ## Living examples (shipped in the npm package)
14
+
15
+ - [`demo/`](demo/) — runnable stand (`npm run demo`): shared cursors in two browser tabs over the
16
+ Peer SDK, relay ⇄ WebRTC direct hand-off next to a legacy rpc key.
17
+ - [`replay/`](replay/) · [`observe/`](observe/) · [`oracle/`](oracle/) — the oracle suites CI runs on
18
+ every push; each file doubles as a worked usage example of one subsystem.
19
+ - Note: examples import from the repo's `src/`; in the installed package the same API comes from
20
+ `wenay-common2` / `wenay-common2/peer` / `wenay-common2/replay` / `wenay-common2/observe`.
package/demo/client.ts ADDED
@@ -0,0 +1,100 @@
1
+ // Demo stand client: shared cursors over the Peer SDK — relay by default,
2
+ // "Go direct" promotes to a real RTCPeerConnection datachannel, "Back to relay"
3
+ // re-interposes. The route hand-off is gap-free by seq; the cursor never jumps.
4
+ import {io} from 'socket.io-client'
5
+ import {createRpcClientHub} from '../src/Common/rcp/rpc-clientHub'
6
+ import {createPeerClient} from '../src/Common/peer/peer-index'
7
+
8
+ type World = {cursor: {x: number, y: number}, color: string, name: string}
9
+
10
+ const params = new URLSearchParams(location.search)
11
+ const me = params.get('me') ?? 'a'
12
+ const other = params.get('peer') ?? (me == 'a' ? 'b' : 'a')
13
+
14
+ const el = (id: string) => document.getElementById(id)!
15
+ const logBox = () => el('log') as HTMLDivElement
16
+
17
+ function log(line: string) {
18
+ const box = logBox()
19
+ const row = document.createElement('div')
20
+ row.textContent = `${new Date().toLocaleTimeString()} ${line}`
21
+ box.prepend(row)
22
+ while (box.children.length > 30) box.lastChild?.remove()
23
+ }
24
+
25
+ async function main() {
26
+ document.title = `peer ${me}`
27
+ el('who').textContent = `me: ${me} · watching: ${other}`
28
+
29
+ const hub = createRpcClientHub(
30
+ () => io({transports: ['websocket'], auth: {account: me}}),
31
+ r => ({app: r<any>('app')}) as const,
32
+ )
33
+ const clients = await hub.setToken(null)
34
+ await clients.app.readyStrict()
35
+ log('rpc connected; legacy serverTime() = ' + await clients.app.func.serverTime())
36
+
37
+ // debug tap: every signaling envelope this account receives
38
+ ;(clients.app.func.peer.signal.signals as any).on((env: any) => {
39
+ log(`sig<- ${env.type} ${env.from}->${env.to}` +
40
+ (env.sdp ? ` sdp.len=${String(env.sdp).length}` : '') +
41
+ (env.candidate ? ` cand=${JSON.stringify(env.candidate).slice(0, 70)}` : ''))
42
+ })
43
+
44
+ const client = createPeerClient<World>({
45
+ remote: clients.app.func.peer,
46
+ account: me,
47
+ initial: {
48
+ cursor: {x: 160, y: 120},
49
+ color: me == 'a' ? '#4f8ef7' : '#f7a44f',
50
+ name: me,
51
+ },
52
+ rtc: () => new RTCPeerConnection(),
53
+ drain: 'micro',
54
+ })
55
+ const peer = client.peer(other)
56
+ client.onRoute(ev => log(`route ${ev.key}: ${ev.from} -> ${ev.to}${ev.reason ? ` (${String(ev.reason)})` : ''}`))
57
+ peer.ready.then(() => log('peer mirror ready (keyframe landed)')).catch(e => log('mirror error: ' + e))
58
+
59
+ // ============== input: own cursor -> own store ==============
60
+ const canvas = el('canvas') as HTMLCanvasElement
61
+ canvas.addEventListener('mousemove', function onMove(e) {
62
+ const r = canvas.getBoundingClientRect()
63
+ client.store.state.cursor = {x: Math.round(e.clientX - r.left), y: Math.round(e.clientY - r.top)}
64
+ })
65
+
66
+ // ============== render loop: own + mirrored cursor ==============
67
+ const ctx = canvas.getContext('2d')!
68
+ function drawCursor(c: {x: number, y: number}, color: string, name: string) {
69
+ ctx.beginPath()
70
+ ctx.arc(c.x, c.y, 8, 0, Math.PI * 2)
71
+ ctx.fillStyle = color
72
+ ctx.fill()
73
+ ctx.fillStyle = '#333'
74
+ ctx.font = '12px sans-serif'
75
+ ctx.fillText(name, c.x + 12, c.y + 4)
76
+ }
77
+ function frame() {
78
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
79
+ const mine = client.store.state
80
+ drawCursor(mine.cursor, mine.color, mine.name + ' (me)')
81
+ const theirs = peer.store.state
82
+ if (theirs?.cursor) drawCursor(theirs.cursor, theirs.color ?? '#999', (theirs.name ?? other) + ` [${peer.route()}]`)
83
+ el('route').textContent = `route: ${peer.route()} · state: ${peer.state()} · seq: ${peer.seq()}`
84
+ requestAnimationFrame(frame)
85
+ }
86
+ frame()
87
+
88
+ // ============== route controls ==============
89
+ el('direct').addEventListener('click', async function goDirect() {
90
+ log('promoteDirect...')
91
+ const res = await peer.promoteDirect({timeoutMs: 8000})
92
+ log(res.ok ? `direct: ok (${res.state})` : `direct failed: ${String(res.reason)}`)
93
+ })
94
+ el('relay').addEventListener('click', async function backToRelay() {
95
+ const res = await peer.reinterposeRelay('manual')
96
+ log(res.ok ? 'back on relay' : `re-interpose failed: ${String(res.reason)}`)
97
+ })
98
+ }
99
+
100
+ main().catch(e => { console.error(e); log('FATAL: ' + e) })
@@ -0,0 +1,28 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>wenay-common2 peer stand</title>
6
+ <style>
7
+ body { font-family: sans-serif; margin: 16px; color: #222; }
8
+ #canvas { border: 1px solid #bbb; border-radius: 6px; background: #fafafa; display: block; }
9
+ #bar { display: flex; gap: 12px; align-items: center; margin: 8px 0; flex-wrap: wrap; }
10
+ #route { font-family: monospace; }
11
+ button { padding: 6px 14px; border-radius: 6px; border: 1px solid #888; background: #fff; cursor: pointer; }
12
+ button:hover { background: #f0f0f0; }
13
+ #log { font-family: monospace; font-size: 12px; margin-top: 8px; max-height: 180px; overflow-y: auto; color: #555; }
14
+ </style>
15
+ </head>
16
+ <body>
17
+ <h3>wenay-common2 — shared cursors (relay ⇄ WebRTC direct)</h3>
18
+ <div id="bar">
19
+ <span id="who">connecting…</span>
20
+ <button id="direct">Go direct</button>
21
+ <button id="relay">Back to relay</button>
22
+ <span id="route"></span>
23
+ </div>
24
+ <canvas id="canvas" width="640" height="360"></canvas>
25
+ <div id="log"></div>
26
+ <script src="client.js"></script>
27
+ </body>
28
+ </html>
package/demo/server.ts ADDED
@@ -0,0 +1,43 @@
1
+ // Demo stand server: the Peer SDK next to a legacy rpc key, static page hosting.
2
+ // Run: npm run demo -> open the two printed URLs in two tabs.
3
+ import express from 'express'
4
+ import {createServer} from 'http'
5
+ import path from 'path'
6
+ import {Server as SocketIOServer} from 'socket.io'
7
+ import {listen} from '../src/Common/events/Listen'
8
+ import {createPeerHost} from '../src/Common/peer/peer-index'
9
+ import {createRpcServerAuto} from '../src/Common/rcp/rpc-server-auto'
10
+
11
+ const PORT = Number(process.env.PORT ?? 8390)
12
+
13
+ const host = createPeerHost()
14
+ const app = express()
15
+ app.use(express.static(path.resolve(__dirname, 'public')))
16
+ app.get('/', (_req, res) => res.sendFile(path.resolve(__dirname, 'public', 'index.html')))
17
+
18
+ const httpServer = createServer(app)
19
+ const ioServer = new SocketIOServer(httpServer)
20
+
21
+ ioServer.on('connection', function onDemoConnection(socket) {
22
+ const account = String(socket.handshake.auth?.account ?? 'anon')
23
+ const peer = host.connection(account)
24
+ const [disconnect, disconnectListen] = listen<[]>()
25
+ socket.on('disconnect', () => { disconnect(); peer.close() })
26
+ createRpcServerAuto({
27
+ socket: {emit: (key, data) => socket.emit(key, data), on: (key, cb) => socket.on(key, cb)},
28
+ socketKey: 'app',
29
+ object: {
30
+ // legacy key on the SAME connection — the SDK does not displace old code
31
+ serverTime: () => new Date().toISOString(),
32
+ peer: peer.fragment,
33
+ },
34
+ disconnectListen,
35
+ })
36
+ console.log(`[demo] ${account} connected`)
37
+ })
38
+
39
+ httpServer.listen(PORT, function onDemoListen() {
40
+ console.log('[demo] shared-cursor stand is up:')
41
+ console.log(` tab A: http://localhost:${PORT}/?me=a&peer=b`)
42
+ console.log(` tab B: http://localhost:${PORT}/?me=b&peer=a`)
43
+ })
@@ -0,0 +1,4 @@
1
+ # 1.0.70
2
+
3
+ - Ship the living examples in the npm package: the oracle suites (`replay/`, `observe/`, `oracle/`) and the demo stand (`demo/`, without the generated bundle) — each oracle doubles as a worked usage example of one subsystem, kept honest by CI on every push.
4
+ - README: a "Living examples" section with the src-vs-package import note (examples import the repo's `src/`; installed packages use `wenay-common2` / `wenay-common2/peer` / `wenay-common2/replay` / `wenay-common2/observe`).
@@ -0,0 +1,131 @@
1
+ # observe — status and contract
2
+
3
+ `observe` is the small reactive core for coarse, consistent invalidation.
4
+ It is intentionally not a MobX/Vue clone: the API does not expose deltas,
5
+ paths, old values, or computed values.
6
+
7
+ Implemented surface:
8
+
9
+ ```ts
10
+ import {reactive, onUpdate, flushReactive, listenUpdate} from './reactive'
11
+
12
+ const state = reactive({
13
+ price: 100,
14
+ balances: {BTC: 1, ETH: 10},
15
+ })
16
+
17
+ const off = onUpdate(state.balances, () => {
18
+ const total = Object.values(state.balances).reduce((a, b) => a + b, 0)
19
+ })
20
+
21
+ state.balances.BTC = 2
22
+ state.balances = {SOL: 250, DOT: 150, ADA: 100}
23
+ await flushReactive(state.balances)
24
+ off()
25
+ ```
26
+
27
+ ## Core idea
28
+
29
+ Subscribe to the fact that something under a node changed. The callback receives
30
+ no `(key, value)`, no diff, and no string path. It fires after the settled batch;
31
+ the consumer re-reads the current state through normal typed property access.
32
+
33
+ This is the intended trading/backend use case:
34
+
35
+ - a feed applies several synchronous mutations;
36
+ - subscribers fire once after the batch;
37
+ - aggregate code reads a consistent snapshot;
38
+ - replacing a whole subtree does not break existing subscribers on that subtree.
39
+
40
+ ## What is implemented
41
+
42
+ - `reactive(obj, opts?)` wraps a plain object/array tree in stable proxies.
43
+ - `onUpdate(node, cb)` subscribes to a reactive node and returns an idempotent `off()`.
44
+ - `flushReactive(node)` resolves after queued drains for that reactive tree settle.
45
+ - `listenUpdate(node)` adapts a node to the project's existing `Listen` shape for RPC.
46
+ - Synchronous bursts are coalesced into one callback per subscribed node.
47
+ - A callback that mutates state queues a follow-up drain instead of recursing.
48
+ - A throwing callback does not stop sibling subscribers; the first error is re-thrown asynchronously.
49
+ - Whole-subtree replacement preserves existing child proxy identity where possible.
50
+ - `depth` makes deeper objects opaque leaves.
51
+ - `eager` pre-walks/wraps the tree to `depth`.
52
+ - `drain` is pluggable: `'immediate'`, `'micro'`, `number`, or custom scheduler.
53
+ - Plain objects and arrays are reactive. `Date`, `Map`, `Set`, and class instances are opaque leaves.
54
+
55
+ ## Chosen laziness model
56
+
57
+ The original design considered subscription-driven wrapping with `subtreeSubs`.
58
+ The implemented model is simpler and is now the contract:
59
+
60
+ - root is always a proxy;
61
+ - child proxies are created lazily on read, up to `depth`;
62
+ - with zero subscribers, writes only mutate and do not schedule drains;
63
+ - built-in/class objects are not proxied.
64
+
65
+ So this is **lazy-on-read**, not subscription-driven wrapping. That is a conscious
66
+ tradeoff: it keeps normal `onUpdate(state.a.b, cb)` usage simple and avoids the
67
+ identity caveat of fully lazy upgrades.
68
+
69
+ ## Options
70
+
71
+ ```ts
72
+ type Drain =
73
+ | 'micro'
74
+ | 'immediate'
75
+ | number
76
+ | ((flush: () => void) => void)
77
+
78
+ type Opts = {
79
+ drain?: Drain // default: 'immediate'
80
+ depth?: number // default: Infinity
81
+ eager?: boolean // default: false
82
+ }
83
+ ```
84
+
85
+ Drain semantics:
86
+
87
+ - `'immediate'`: uses `setImmediate` when available, otherwise `setTimeout(0)`.
88
+ - `'micro'`: uses `queueMicrotask`.
89
+ - `number`: uses `setTimeout(ms)` and coalesces to the last state.
90
+ - custom scheduler: receives the flush function.
91
+
92
+ Use `flushReactive(node)` in tests/examples instead of guessing event-loop order.
93
+
94
+ ## Verified by oracle
95
+
96
+ Run:
97
+
98
+ ```bash
99
+ npx tsx observe/reactive.test.ts
100
+ npx tsx observe/usage.ts
101
+ ```
102
+
103
+ The oracle covers:
104
+
105
+ - plain and nested read/write;
106
+ - cold writes with no subscribers;
107
+ - one fact per batch;
108
+ - the `$500` whole-map replacement case;
109
+ - subscriber survival across middle-node replacement;
110
+ - root subscriber behavior;
111
+ - unsubscribe before flush;
112
+ - mutation from inside callback;
113
+ - arrays;
114
+ - opaque `Date`/`Map`/class leaves;
115
+ - `depth` behavior;
116
+ - callback error isolation.
117
+
118
+ ## Not implemented here
119
+
120
+ These are intentionally not part of the finished local core:
121
+
122
+ - per-key delta streams;
123
+ - string paths;
124
+ - `computed`/derived graph primitives;
125
+ - dependency tracking by reads;
126
+ - automatic network mirror over RPC.
127
+
128
+ `listenUpdate(node)` already stacks with `createRpcServerAuto` as a notification
129
+ stream. The larger mirror extension remains separate: subscribe to a subtree,
130
+ send whole snapshots on settle/throttle, and atomically replace the mirror branch
131
+ on the client.
@@ -0,0 +1,226 @@
1
+ # observe
2
+
3
+ Minimal reactive object core for coarse, consistent invalidation.
4
+
5
+ The idea is simple: subscribe to the fact that a reactive subtree changed, then
6
+ re-read the current state. There are no deltas, no string paths, no old/new
7
+ values, and no computed graph in the core.
8
+
9
+ ## API
10
+
11
+ ```ts
12
+ import {reactive, onUpdate, flushReactive, listenUpdate} from './reactive'
13
+ ```
14
+
15
+ ### `reactive(obj, opts?)`
16
+
17
+ Wraps a plain object/array tree.
18
+
19
+ ```ts
20
+ const state = reactive({
21
+ balances: {BTC: 100, ETH: 400},
22
+ price: 0,
23
+ })
24
+ ```
25
+
26
+ You read and write normally:
27
+
28
+ ```ts
29
+ state.price = 101
30
+ state.balances.BTC = 120
31
+ state.balances = {SOL: 250, DOT: 150, ADA: 100}
32
+ ```
33
+
34
+ ### `onUpdate(node, cb)`
35
+
36
+ Subscribes to a node. The callback receives no arguments. It means only:
37
+
38
+ > Something under this node changed; the batch is settled; read the state now.
39
+
40
+ ```ts
41
+ const off = onUpdate(state.balances, () => {
42
+ const total = Object.values(state.balances).reduce((a, b) => a + b, 0)
43
+ console.log(total)
44
+ })
45
+
46
+ off()
47
+ ```
48
+
49
+ ### `flushReactive(node)`
50
+
51
+ Returns a promise that resolves after queued updates for this reactive tree
52
+ settle. Use it in tests, demos, and scripts instead of guessing event-loop order.
53
+
54
+ ```ts
55
+ state.balances.BTC = 200
56
+ await flushReactive(state.balances)
57
+ ```
58
+
59
+ ### `listenUpdate(node)`
60
+
61
+ Adapts an Observe node to the project's existing `Listen` shape. This is the
62
+ bridge for RPC: `createRpcServerAuto` already knows how to expose `Listen`
63
+ objects as remote subscriptions.
64
+
65
+ ```ts
66
+ const facade = {
67
+ getBalances: () => state.balances,
68
+ balancesChanged: listenUpdate(state.balances),
69
+ }
70
+ ```
71
+
72
+ ## Full Example
73
+
74
+ ```ts
75
+ import {reactive, onUpdate, flushReactive} from './reactive'
76
+
77
+ const sum = (o: Record<string, number>) =>
78
+ Object.values(o).reduce((a, b) => a + b, 0)
79
+
80
+ async function main() {
81
+ const account = reactive({
82
+ balances: {BTC: 100, ETH: 400},
83
+ positions: {
84
+ BTC: {qty: 0.5},
85
+ },
86
+ })
87
+
88
+ let balanceRecalc = 0
89
+ let positionSync = 0
90
+
91
+ const offBalances = onUpdate(account.balances, () => {
92
+ balanceRecalc++
93
+ console.log('balances total:', sum(account.balances))
94
+ })
95
+
96
+ const offPositions = onUpdate(account.positions, () => {
97
+ positionSync++
98
+ console.log('positions changed:', Object.keys(account.positions))
99
+ })
100
+
101
+ // Several synchronous mutations become one callback.
102
+ account.balances.BTC = 120
103
+ account.balances.ETH = 380
104
+ account.balances.SOL = 0
105
+ await flushReactive(account.balances)
106
+
107
+ console.log('balance recalculations:', balanceRecalc) // 1
108
+
109
+ // Whole-subtree replacement is the important case:
110
+ // the old account.balances proxy keeps working as a subscription target.
111
+ account.balances = {SOL: 250, DOT: 150, ADA: 100}
112
+ await flushReactive(account.balances)
113
+
114
+ console.log('new total:', sum(account.balances)) // 500
115
+ console.log('balance recalculations:', balanceRecalc) // 2
116
+
117
+ // Deep writes under an already-read branch are reactive.
118
+ account.positions.BTC.qty = 0.7
119
+ account.positions.ETH = {qty: 3}
120
+ await flushReactive(account.positions)
121
+
122
+ console.log('position syncs:', positionSync) // 1
123
+
124
+ offBalances()
125
+ offPositions()
126
+ }
127
+
128
+ main()
129
+ ```
130
+
131
+ ## Options
132
+
133
+ ```ts
134
+ type Opts = {
135
+ drain?: 'immediate' | 'micro' | number | ((flush: () => void) => void)
136
+ depth?: number
137
+ eager?: boolean
138
+ }
139
+ ```
140
+
141
+ Defaults:
142
+
143
+ ```ts
144
+ reactive(obj, {
145
+ drain: 'immediate',
146
+ depth: Infinity,
147
+ eager: false,
148
+ })
149
+ ```
150
+
151
+ Drain modes:
152
+
153
+ - `'immediate'`: `setImmediate` when available, otherwise `setTimeout(0)`.
154
+ - `'micro'`: `queueMicrotask`.
155
+ - `number`: `setTimeout(number)`, useful as a throttle.
156
+ - custom function: bring your own scheduler.
157
+
158
+ Example throttle:
159
+
160
+ ```ts
161
+ const md = reactive({price: 0}, {drain: 50})
162
+ let heavyRecalc = 0
163
+
164
+ onUpdate(md, () => {
165
+ heavyRecalc++
166
+ })
167
+
168
+ for (let i = 0; i < 1000; i++) md.price = i
169
+ await flushReactive(md)
170
+
171
+ console.log(heavyRecalc) // 1
172
+ ```
173
+
174
+ ## Current Contract
175
+
176
+ - Root is always a proxy.
177
+ - Child proxies are created lazily on read.
178
+ - With zero subscribers, writes do not schedule drains.
179
+ - Plain objects and arrays are reactive.
180
+ - `Date`, `Map`, `Set`, and class instances are opaque leaves.
181
+ - Callback errors do not stop sibling subscribers; the first error is re-thrown asynchronously.
182
+ - A callback that mutates state queues a follow-up drain instead of recursing.
183
+
184
+ ## What Was Finished
185
+
186
+ - Local in-process core: `reactive`, `onUpdate`, `flushReactive`.
187
+ - RPC bridge: `listenUpdate(node)` returns a `Listen` object.
188
+ - Coalesced settled updates.
189
+ - Whole-subtree replacement.
190
+ - Depth/eager/drain options.
191
+ - Edge-case oracle in `reactive.test.ts`.
192
+ - Usage scenarios in `usage.ts`.
193
+
194
+ ## What Is Not Part Of This Core
195
+
196
+ - Per-key delta streams.
197
+ - String-path subscriptions.
198
+ - `computed` or dependency tracking.
199
+ - Automatic RPC snapshot/mirror layer.
200
+
201
+ Those live in the layers built ON TOP of this core, not inside it.
202
+
203
+ ## Where This Went
204
+
205
+ The sandbox has been assembled: the canonical core is
206
+ `src/Common/Observe/reactive.ts`, exported from the package as `Observe`.
207
+ Layers on top of it (documented in `wenay-common2.md` / `wenay-common2-rare.md`):
208
+
209
+ - **Store** — `createStore` wraps the core with typed path nodes (`state` / `node` /
210
+ `update(mask)`), `src/Common/Observe/store.ts`.
211
+ - **Mirror sync** — `exposeStore` ⇄ `createStoreMirror`: snapshots + changed
212
+ notifications over RPC, optional push channels (`patches` / `changedData`).
213
+ - **Sequenced replay line** — the store's patch stream numbered by seq: keyframe
214
+ catch-up, reconnect by seq, per-client conflation, archived history with
215
+ time-travel. Exported as `Replay` (generic line) and via `Observe`
216
+ (`exposeStoreReplay` / `syncStoreReplay` / `storeReplayAt`); oracles in `replay/`,
217
+ status in `replay/PLAN.md`.
218
+
219
+ ## Run
220
+
221
+ ```bash
222
+ npx tsx observe/reactive.test.ts
223
+ npx tsx observe/store.test.ts
224
+ npx tsx observe/usage.ts
225
+ npx tsx observe/usage-real-socket.ts
226
+ ```
@@ -0,0 +1,95 @@
1
+ // ============================================================
2
+ // observe/hot-write.test.ts
3
+ //
4
+ // Hot-write масштабирование reactive: тикающая карта котировок
5
+ // (state[symbol] = quote, тысячи разных ключей за drain-окно) при
6
+ // подключённом changedPaths-потребителе (pathLive > 0).
7
+ // Оракул: дедуп dirty-путей keyed-структурой (не линейным сканом) →
8
+ // окно из 4x ключей стоит ~4x времени, НЕ ~16x. Плюс корректность:
9
+ // дедуп в окне, symbol-идентичность ключей, отсутствие склейки
10
+ // строковых сегментов.
11
+ // Запуск:
12
+ // npx ts-node observe/hot-write.test.ts
13
+ // ============================================================
14
+
15
+ import {reactive, onUpdatePaths, flushReactive} from '../src/Common/Observe/reactive'
16
+
17
+ let fails = 0
18
+ const ok = (condition: any, message: string) => {
19
+ if (!condition) { fails++; console.log(' FAIL', message) }
20
+ else console.log(' OK ', message)
21
+ }
22
+
23
+ async function benchWindow(n: number, rounds: number) {
24
+ const state = reactive<any>({quotes: {}}, {drain: 'micro'})
25
+ for (let i = 0; i < n; i++) state.quotes['s' + i] = 0 // ключи заранее: меряем чистые записи
26
+ const off = onUpdatePaths(state, () => {})
27
+ await flushReactive(state)
28
+ let best = Infinity
29
+ for (let r = 1; r <= rounds; r++) {
30
+ const t0 = process.hrtime.bigint()
31
+ for (let i = 0; i < n; i++) state.quotes['s' + i] = r * n + i
32
+ await flushReactive(state)
33
+ best = Math.min(best, Number(process.hrtime.bigint() - t0) / 1e6)
34
+ }
35
+ off()
36
+ return best
37
+ }
38
+
39
+ async function main() {
40
+ console.log('\n[hot-write] changedPaths correctness under a hot window')
41
+ {
42
+ const state = reactive<any>({quotes: {}}, {drain: 'micro'})
43
+ const windows: PropertyKey[][][] = []
44
+ const off = onUpdatePaths(state, ch => windows.push(ch.paths))
45
+ for (let i = 0; i < 5; i++) state.quotes['s' + i] = i
46
+ for (let i = 0; i < 5; i++) state.quotes['s' + i] = i + 100 // повторное касание тех же ключей
47
+ await flushReactive(state)
48
+ ok(windows.length == 1, 'one settled batch')
49
+ ok(windows[0].length == 5, `same key touched twice dedups to one path (got ${windows[0].length})`)
50
+ ok(windows[0].every(p => p.length == 2 && p[0] == 'quotes'), 'paths are root-relative [quotes, key]')
51
+ off()
52
+ }
53
+
54
+ console.log('\n[hot-write] symbol keys stay identity-safe in the dedup')
55
+ {
56
+ const s1 = Symbol('k'), s2 = Symbol('k') // одинаковое описание, разная идентичность
57
+ const state = reactive<any>({}, {drain: 'micro'})
58
+ const windows: PropertyKey[][][] = []
59
+ const off = onUpdatePaths(state, ch => windows.push(ch.paths))
60
+ state[s1] = 1
61
+ state[s2] = 2
62
+ state[s1] = 3 // повтор первого символа
63
+ await flushReactive(state)
64
+ ok(windows[0].length == 2, `two same-description symbols = two paths, repeat dedups (got ${windows[0].length})`)
65
+ off()
66
+ }
67
+
68
+ console.log('\n[hot-write] string segments cannot glue across boundaries')
69
+ {
70
+ const state = reactive<any>({'a': {'b|c': 0}, 'a|b': {'c': 0}}, {drain: 'micro'})
71
+ const windows: PropertyKey[][][] = []
72
+ const off = onUpdatePaths(state, ch => windows.push(ch.paths))
73
+ state['a']['b|c'] = 1
74
+ state['a|b']['c'] = 2
75
+ await flushReactive(state)
76
+ ok(windows[0].length == 2, `[a][b|c] and [a|b][c] are distinct paths (got ${windows[0].length})`)
77
+ off()
78
+ }
79
+
80
+ console.log('\n[hot-write] near-linear scaling with a changedPaths subscriber')
81
+ {
82
+ await benchWindow(200, 2) // прогрев (JIT)
83
+ const n = 500, m = 2000, rounds = 5
84
+ const tN = await benchWindow(n, rounds)
85
+ const tM = await benchWindow(m, rounds)
86
+ const ratio = tM / tN
87
+ console.log(` [bench] ${n} keys/window: ${tN.toFixed(2)}ms · ${m} keys/window: ${tM.toFixed(2)}ms · ratio ${ratio.toFixed(1)}x`)
88
+ ok(ratio < 10, `4x keys cost ~4x, not ~16x (ratio ${ratio.toFixed(1)}x < 10x)`)
89
+ }
90
+
91
+ console.log(fails ? `\n${fails} FAILED` : '\nall passed')
92
+ if (fails) process.exit(1)
93
+ }
94
+
95
+ main().catch(e => { console.error(e); process.exit(1) })