wenay-common2 1.0.68 → 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 +12 -1
- package/demo/client.ts +100 -0
- package/demo/index.html +28 -0
- package/demo/server.ts +43 -0
- package/doc/ROADMAP.md +9 -3
- package/doc/changes/1.0.69.md +11 -0
- package/doc/changes/1.0.70.md +4 -0
- package/doc/wenay-common2-rare.md +8 -0
- package/doc/wenay-common2.md +42 -6
- package/lib/Common/events/route-signal-webrtc.js +4 -2
- package/lib/Common/peer/peer-client.d.ts +46 -0
- package/lib/Common/peer/peer-client.js +108 -0
- package/lib/Common/peer/peer-host.d.ts +32 -0
- package/lib/Common/peer/peer-host.js +45 -0
- package/lib/Common/peer/peer-index.d.ts +3 -0
- package/lib/Common/peer/peer-index.js +19 -0
- package/lib/Common/peer/peer-relay.d.ts +14 -0
- package/lib/Common/peer/peer-relay.js +77 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +2 -1
- 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 +8 -2
- 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,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createPatchRelayJournal = createPatchRelayJournal;
|
|
4
|
+
const Listen_1 = require("../events/Listen");
|
|
5
|
+
const store_1 = require("../Observe/store");
|
|
6
|
+
const store_replay_1 = require("../Observe/store-replay");
|
|
7
|
+
function condensePatchTail(tail) {
|
|
8
|
+
const held = new Map();
|
|
9
|
+
for (const ev of tail) {
|
|
10
|
+
const k = (0, store_replay_1.storePatchKey)(ev.event[0]);
|
|
11
|
+
if (k == null)
|
|
12
|
+
return tail;
|
|
13
|
+
held.delete(k);
|
|
14
|
+
held.set(k, ev);
|
|
15
|
+
}
|
|
16
|
+
return Array.from(held.values());
|
|
17
|
+
}
|
|
18
|
+
function createPatchRelayJournal(opts = {}) {
|
|
19
|
+
const { history = 1024 } = opts;
|
|
20
|
+
const [emitLine, line] = (0, Listen_1.listen)();
|
|
21
|
+
const fold = (0, store_1.createStore)({}, { drain: 'micro' });
|
|
22
|
+
let ring = [];
|
|
23
|
+
let last = -1;
|
|
24
|
+
let lastTs = 0;
|
|
25
|
+
let hasState = false;
|
|
26
|
+
function push(env) {
|
|
27
|
+
if (env == null || typeof env.seq != 'number')
|
|
28
|
+
return false;
|
|
29
|
+
const patch = env.event?.[0];
|
|
30
|
+
if (patch == null || !Array.isArray(patch.path))
|
|
31
|
+
return false;
|
|
32
|
+
const isRoot = patch.path.length == 0;
|
|
33
|
+
if (env.seq <= last) {
|
|
34
|
+
if (!isRoot || env.seq == last)
|
|
35
|
+
return false;
|
|
36
|
+
ring = [];
|
|
37
|
+
}
|
|
38
|
+
last = env.seq;
|
|
39
|
+
lastTs = env.ts ?? lastTs;
|
|
40
|
+
hasState = true;
|
|
41
|
+
(0, store_1.applyStorePatch)(fold, patch);
|
|
42
|
+
ring.push(env);
|
|
43
|
+
if (ring.length > history)
|
|
44
|
+
ring.splice(0, ring.length - history);
|
|
45
|
+
emitLine(env);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
function keyframe() {
|
|
49
|
+
if (!hasState)
|
|
50
|
+
return null;
|
|
51
|
+
return { seq: last, ts: lastTs, event: [{ path: [], exists: true, value: fold.snapshot() }] };
|
|
52
|
+
}
|
|
53
|
+
function since(seq) {
|
|
54
|
+
if (!hasState)
|
|
55
|
+
return null;
|
|
56
|
+
if (seq >= last)
|
|
57
|
+
return [];
|
|
58
|
+
if (!ring.length || seq < ring[0].seq - 1)
|
|
59
|
+
return null;
|
|
60
|
+
return ring.filter(ev => ev.seq > seq);
|
|
61
|
+
}
|
|
62
|
+
function frame(seq, _hint) {
|
|
63
|
+
const tail = since(seq);
|
|
64
|
+
if (tail)
|
|
65
|
+
return condensePatchTail(tail);
|
|
66
|
+
const kf = keyframe();
|
|
67
|
+
return kf ? [kf] : null;
|
|
68
|
+
}
|
|
69
|
+
const remote = { line, since, keyframe, frame };
|
|
70
|
+
return {
|
|
71
|
+
push,
|
|
72
|
+
remote,
|
|
73
|
+
seq: () => last,
|
|
74
|
+
snapshot: () => fold.snapshot(),
|
|
75
|
+
close: () => { line.close(); },
|
|
76
|
+
};
|
|
77
|
+
}
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -36,7 +36,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
36
36
|
};
|
|
37
37
|
})();
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.Media = exports.Replay = exports.Observe = exports.Color = exports.Time = exports.Params = exports.Bars = exports.Math = void 0;
|
|
39
|
+
exports.Peer = exports.Media = exports.Replay = exports.Observe = exports.Color = exports.Time = exports.Params = exports.Bars = exports.Math = void 0;
|
|
40
40
|
__exportStar(require("./Common/node_console"), exports);
|
|
41
41
|
__exportStar(require("./Common/core/Decorator"), exports);
|
|
42
42
|
__exportStar(require("./Common/core/BaseTypes"), exports);
|
|
@@ -74,3 +74,4 @@ exports.Color = __importStar(require("./Common/Color"));
|
|
|
74
74
|
exports.Observe = __importStar(require("./Common/Observe"));
|
|
75
75
|
exports.Replay = __importStar(require("./Common/events/replay-index"));
|
|
76
76
|
exports.Media = __importStar(require("./Common/media/media-index"));
|
|
77
|
+
exports.Peer = __importStar(require("./Common/peer/peer-index"));
|
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) })
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {createListen, createListenCore, listenStore} from '../src/Common/events/Listen'
|
|
2
|
+
|
|
3
|
+
let fails = 0
|
|
4
|
+
const ok = (c: any, m: string) => { if (!c) { fails++; console.log(' FAIL', m) } else console.log(' OK ', m) }
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
console.log('\n[slim-listen] core on/off/once')
|
|
8
|
+
{
|
|
9
|
+
const listen = createListenCore<[number]>()
|
|
10
|
+
const seen: number[] = []
|
|
11
|
+
const off = listen.on(v => seen.push(v))
|
|
12
|
+
listen.emit(1)
|
|
13
|
+
off()
|
|
14
|
+
listen.emit(2)
|
|
15
|
+
listen.once(v => seen.push(v * 10))
|
|
16
|
+
listen.emit(3)
|
|
17
|
+
listen.emit(4)
|
|
18
|
+
ok(JSON.stringify(seen) == '[1,30]', 'core delivers on, removes by off, and once fires once')
|
|
19
|
+
ok(listen.count() == 0, 'core once removes itself')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
console.log('\n[slim-listen] core key replacement')
|
|
23
|
+
{
|
|
24
|
+
const listen = createListenCore<[number]>()
|
|
25
|
+
const seen: string[] = []
|
|
26
|
+
listen.on(v => seen.push('a' + v), {key: 'slot'})
|
|
27
|
+
listen.on(v => seen.push('b' + v), {key: 'slot'})
|
|
28
|
+
listen.emit(5)
|
|
29
|
+
ok(JSON.stringify(seen) == '["b5"]', 'same key replaces previous callback')
|
|
30
|
+
ok(listen.count() == 1, 'key replacement keeps one live subscriber')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
console.log('\n[slim-listen] full lifecycle and close hooks')
|
|
34
|
+
{
|
|
35
|
+
let producerClosed = 0
|
|
36
|
+
let cbClosed = 0
|
|
37
|
+
let streamClosed = 0
|
|
38
|
+
const listen = createListen<[number]>(() => () => { producerClosed++ })
|
|
39
|
+
listen.run()
|
|
40
|
+
const off = listen.on(() => {}, {cbClose: () => { cbClosed++ }})
|
|
41
|
+
off()
|
|
42
|
+
listen.on(() => {}, {cbClose: () => { cbClosed++ }})
|
|
43
|
+
listen.onClose(() => { streamClosed++ })
|
|
44
|
+
listen.close()
|
|
45
|
+
ok(cbClosed == 1, 'cbClose fires on full close for live subscribers only')
|
|
46
|
+
ok(producerClosed == 1, 'producer cleanup fires on close')
|
|
47
|
+
ok(streamClosed == 1, 'onClose fires on close')
|
|
48
|
+
ok(listen.count() == 0, 'full close clears listeners')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log('\n[slim-listen] store once(current) waits when current is absent')
|
|
52
|
+
{
|
|
53
|
+
const [emit, listen] = listenStore<[number]>({current: () => undefined})
|
|
54
|
+
const seen: number[] = []
|
|
55
|
+
listen.once(v => seen.push(v), {current: true})
|
|
56
|
+
emit(8)
|
|
57
|
+
emit(9)
|
|
58
|
+
ok(JSON.stringify(seen) == '[8]', 'once(current) falls back to one future event when store has no value')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log(`\n${fails == 0 ? 'ALL GREEN' : fails + ' FAILURE(S)'}`)
|
|
62
|
+
process.exit(fails == 0 ? 0 : 1)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
main().catch(e => { console.error(e); process.exit(1) })
|
|
66
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {listen as createListenPair, listenStore} from '../src/Common/events/Listen'
|
|
2
|
+
|
|
3
|
+
let fails = 0
|
|
4
|
+
const ok = (c: any, m: string) => { if (!c) { fails++; console.log(' FAIL', m) } else console.log(' OK ', m) }
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
console.log('\n[listen-store] base listen is a pure event list')
|
|
8
|
+
{
|
|
9
|
+
const [emit, listen] = createListenPair<[number]>()
|
|
10
|
+
emit(1)
|
|
11
|
+
let got = 0
|
|
12
|
+
;(listen.on as any)((v: number) => { got = v }, {current: true})
|
|
13
|
+
ok(got == 0, 'base listen ignores current and stores no value')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
console.log('\n[listen-store] listenStore reads external store reference')
|
|
17
|
+
{
|
|
18
|
+
const box = {value: 1}
|
|
19
|
+
const [emit, listen] = listenStore<[number]>({current: () => [box.value]})
|
|
20
|
+
emit(99)
|
|
21
|
+
let got = 0
|
|
22
|
+
listen.on(v => { got = v }, {current: true})
|
|
23
|
+
ok(got == 1, 'current:true reads store provider, not emitted args')
|
|
24
|
+
box.value = 9
|
|
25
|
+
got = 0
|
|
26
|
+
listen.once(v => { got = v }, {current: true})
|
|
27
|
+
ok(got == 9, 'once(current) reads changed external value')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log('\n[listen-store] per-subscription getter overrides wrapper provider')
|
|
31
|
+
{
|
|
32
|
+
const box = {value: 1}
|
|
33
|
+
const [emit, listen] = listenStore<[number]>({current: () => [box.value]})
|
|
34
|
+
emit(7)
|
|
35
|
+
let got = 0
|
|
36
|
+
listen.on(v => { got = v }, {current: () => [42]})
|
|
37
|
+
ok(got == 42, 'per-call current getter is used for that subscriber')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.log('\n[listen-store] normal events still flow through the wrapper')
|
|
41
|
+
{
|
|
42
|
+
const box = {value: 1}
|
|
43
|
+
const [emit, listen] = listenStore<[number]>({current: () => [box.value]})
|
|
44
|
+
const seen: number[] = []
|
|
45
|
+
const off = listen.on(v => seen.push(v))
|
|
46
|
+
emit(5)
|
|
47
|
+
off()
|
|
48
|
+
emit(6)
|
|
49
|
+
ok(JSON.stringify(seen) == '[5]', 'wrapper delegates event delivery and off() to base listen')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(`\n${fails == 0 ? 'ALL GREEN' : fails + ' FAILURE(S)'}`)
|
|
53
|
+
process.exit(fails == 0 ? 0 : 1)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
main().catch(e => { console.error(e); process.exit(1) })
|