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
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) })
|
package/demo/index.html
ADDED
|
@@ -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`).
|
package/observe/PLAN.md
ADDED
|
@@ -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) })
|