wenay-common2 1.0.69 → 1.0.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/demo/client.ts +100 -0
- package/demo/index.html +28 -0
- package/demo/server.ts +43 -0
- package/doc/changes/1.0.70.md +4 -0
- package/observe/PLAN.md +131 -0
- package/observe/README.md +226 -0
- package/observe/hot-write.test.ts +95 -0
- package/observe/listen-core.test.ts +66 -0
- package/observe/listen-store.test.ts +56 -0
- package/observe/listen.test.ts +92 -0
- package/observe/reactive.test.ts +270 -0
- package/observe/reactive.ts +1 -0
- package/observe/store-manager.test.ts +118 -0
- package/observe/store-mirror.example.ts +74 -0
- package/observe/store.test.ts +235 -0
- package/observe/store.ts +1 -0
- package/observe/usage-real-socket.ts +174 -0
- package/observe/usage.ts +200 -0
- package/oracle/PASSED.md +27 -0
- package/oracle/README.md +12 -0
- package/oracle/fixes-primitives.spec.ts +90 -0
- package/oracle/realsocket/_rs.ts +106 -0
- package/oracle/realsocket/auth.spec.ts +91 -0
- package/oracle/realsocket/callbacks.spec.ts +122 -0
- package/oracle/realsocket/caps.spec.ts +124 -0
- package/oracle/realsocket/core.spec.ts +49 -0
- package/oracle/realsocket/dedupe.spec.ts +142 -0
- package/oracle/realsocket/errors.spec.ts +121 -0
- package/oracle/realsocket/lifecycle.spec.ts +128 -0
- package/oracle/realsocket/limits.spec.ts +98 -0
- package/oracle/realsocket/pipe.spec.ts +101 -0
- package/oracle/realsocket/shape.spec.ts +124 -0
- package/oracle/realsocket/slimv2.spec.ts +116 -0
- package/oracle/realsocket/stress.spec.ts +132 -0
- package/oracle/regression/_clientapiall-replay.fixture.ts +57 -0
- package/oracle/regression/async-queues.spec.ts +254 -0
- package/oracle/regression/bytestream.spec.ts +152 -0
- package/oracle/regression/clientapiall-replay-types.spec.ts +47 -0
- package/oracle/regression/core-clone-equal.spec.ts +124 -0
- package/oracle/regression/data-structures.spec.ts +135 -0
- package/oracle/regression/listen-events.spec.ts +206 -0
- package/oracle/regression/observe-core.spec.ts +278 -0
- package/oracle/regression/package-export.spec.ts +120 -0
- package/oracle/regression/rpc-dedupe-callbacks.spec.ts +195 -0
- package/oracle/regression/rpc-lifecycle.spec.ts +150 -0
- package/oracle/regression/store-each.spec.ts +209 -0
- package/package.json +6 -1
- package/replay/PLAN.md +171 -0
- package/replay/canvas-socket.test.ts +187 -0
- package/replay/coalesce.test.ts +260 -0
- package/replay/conflate-socket.test.ts +288 -0
- package/replay/conflate.test.ts +225 -0
- package/replay/history.test.ts +222 -0
- package/replay/media-socket.test.ts +157 -0
- package/replay/offline-store-socket.test.ts +290 -0
- package/replay/offline-store.test.ts +156 -0
- package/replay/peer-sdk.test.ts +228 -0
- package/replay/replay-listen.test.ts +156 -0
- package/replay/route-coordinator.test.ts +314 -0
- package/replay/route-handoff.test.ts +150 -0
- package/replay/route-webrtc.test.ts +321 -0
- package/replay/rpc-auto.test.ts +256 -0
- package/replay/socket-replay.test.ts +199 -0
- package/replay/staleness.test.ts +176 -0
- package/replay/store-replay.test.ts +151 -0
- package/replay/video-socket.demo.ts +200 -0
|
@@ -0,0 +1,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) })
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import {createListen, createListenCore, getListenByOn, isListenOn, listenStore} from '../src/Common/events/Listen'
|
|
2
|
+
|
|
3
|
+
let fails = 0
|
|
4
|
+
const ok = (condition: any, message: string) => {
|
|
5
|
+
if (!condition) { fails++; console.log(' FAIL', message) }
|
|
6
|
+
else console.log(' OK ', message)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function main() {
|
|
10
|
+
console.log('\n[listen] core compatibility')
|
|
11
|
+
{
|
|
12
|
+
const listen = createListenCore<[number]>()
|
|
13
|
+
const seen: number[] = []
|
|
14
|
+
const off = listen.on(v => seen.push(v))
|
|
15
|
+
listen.emit(1)
|
|
16
|
+
off()
|
|
17
|
+
listen.emit(2)
|
|
18
|
+
listen.once(v => seen.push(v * 10))
|
|
19
|
+
listen.emit(3)
|
|
20
|
+
listen.emit(4)
|
|
21
|
+
ok(seen.join(',') == '1,30', 'core exposes on/off/once behavior')
|
|
22
|
+
ok(listen.count() == 0, 'core once removes itself')
|
|
23
|
+
ok(isListenOn(listen.on) && getListenByOn(listen.on) === listen, 'core on is registered for Listen identity checks')
|
|
24
|
+
}
|
|
25
|
+
{
|
|
26
|
+
const listen = createListenCore<[number]>()
|
|
27
|
+
const seen: number[] = []
|
|
28
|
+
const cb = (v: number) => seen.push(v)
|
|
29
|
+
const off = listen.on(cb)
|
|
30
|
+
listen.emit(1)
|
|
31
|
+
off()
|
|
32
|
+
listen.emit(2)
|
|
33
|
+
listen.on(cb)
|
|
34
|
+
listen.on(cb)
|
|
35
|
+
listen.on(v => seen.push(v * 10))
|
|
36
|
+
listen.off(cb)
|
|
37
|
+
listen.emit(3)
|
|
38
|
+
ok(seen.join(',') == '1,30', 'off(cb) removes matching callbacks')
|
|
39
|
+
ok(listen.count() == 1, 'off(cb) leaves unrelated subscribers')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log('\n[listen] store once(current) fallback')
|
|
43
|
+
{
|
|
44
|
+
let reads = 0
|
|
45
|
+
const [emit, listen] = listenStore<[number]>({current: () => ++reads == 1 ? undefined : [7]})
|
|
46
|
+
const seen: number[] = []
|
|
47
|
+
listen.once(v => seen.push(v), {current: true})
|
|
48
|
+
emit(8)
|
|
49
|
+
emit(9)
|
|
50
|
+
ok(seen.join(',') == '8', 'once(current) without current waits for one future event')
|
|
51
|
+
ok(reads == 1, 'fallback does not ask current provider twice')
|
|
52
|
+
}
|
|
53
|
+
{
|
|
54
|
+
const [_emit, listen] = listenStore<[number]>({current: () => [5]})
|
|
55
|
+
const seen: number[] = []
|
|
56
|
+
const off = listen.once(v => seen.push(v), {current: true})
|
|
57
|
+
off()
|
|
58
|
+
ok(seen.join(',') == '5' && listen.count() == 0, 'once(current) with current does not subscribe')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log('\n[listen] close hook parity')
|
|
62
|
+
{
|
|
63
|
+
const listen = createListen<[number]>(() => {})
|
|
64
|
+
const order: string[] = []
|
|
65
|
+
listen.onClose(() => order.push('stream1'))
|
|
66
|
+
listen.on(() => {}, {cbClose: () => order.push('sub')})
|
|
67
|
+
listen.onClose(() => order.push('stream2'))
|
|
68
|
+
listen.close()
|
|
69
|
+
ok(order.join(',') == 'stream1,sub,stream2', 'close hooks fire in insertion order like Listen.ts')
|
|
70
|
+
}
|
|
71
|
+
{
|
|
72
|
+
const listen = createListen<[number]>(() => {})
|
|
73
|
+
let closed = 0
|
|
74
|
+
const off = listen.on(() => {}, {cbClose: () => closed++})
|
|
75
|
+
off()
|
|
76
|
+
listen.close()
|
|
77
|
+
ok(closed == 0, 'off removes per-sub cbClose before close')
|
|
78
|
+
}
|
|
79
|
+
{
|
|
80
|
+
const listen = createListen<[number]>(() => {})
|
|
81
|
+
let closed = 0
|
|
82
|
+
listen.on(() => {}, {key: 'slot', cbClose: () => closed++})
|
|
83
|
+
listen.on(() => {}, {key: 'slot', cbClose: () => closed += 10})
|
|
84
|
+
listen.close()
|
|
85
|
+
ok(closed == 10, 'key replacement removes previous cbClose')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log(`\n${fails == 0 ? 'ALL GREEN' : fails + ' FAILURE(S)'}`)
|
|
89
|
+
process.exit(fails == 0 ? 0 : 1)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
main().catch(e => { console.error(e); process.exit(1) })
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// observe/reactive.test.ts — oracle for reactive.ts
|
|
3
|
+
// npx tsx observe/reactive.test.ts
|
|
4
|
+
// ============================================================
|
|
5
|
+
|
|
6
|
+
import {reactive, onUpdate, onUpdatePaths, flushReactive, listenUpdate, listenUpdatePaths, toRaw} from './reactive'
|
|
7
|
+
|
|
8
|
+
type Fn = () => void
|
|
9
|
+
let fails = 0
|
|
10
|
+
const ok = (c: any, m: string) => { if (!c) { fails++; console.log(' FAIL', m) } else console.log(' OK ', m) }
|
|
11
|
+
const sum = (o: any) => Object.values(o).reduce((a: any, b: any) => a + b, 0) as number
|
|
12
|
+
|
|
13
|
+
let pending: Fn | null = null
|
|
14
|
+
const manual = {drain: (f: Fn) => { pending = f }}
|
|
15
|
+
const flush = () => { const f = pending; pending = null; if (f) f() }
|
|
16
|
+
const tick = () => new Promise<void>(resolve => setTimeout(resolve, 0))
|
|
17
|
+
|
|
18
|
+
async function main() {
|
|
19
|
+
console.log('\n[1] plain + nested read/write')
|
|
20
|
+
{
|
|
21
|
+
const s = reactive({price: 0, a: {b: {c: 1}}}, manual)
|
|
22
|
+
s.price = 100; s.a.b.c = 5
|
|
23
|
+
ok(s.price == 100 && s.a.b.c == 5, 'reads/writes at any depth, as a normal object')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
console.log('\n[2] cold = cheap (no subscribers -> set schedules nothing)')
|
|
27
|
+
{
|
|
28
|
+
const s = reactive({x: 0}, manual); pending = null
|
|
29
|
+
s.x = 1; s.x = 2
|
|
30
|
+
ok(pending == null && s.x == 2, 'set with zero subscribers does not schedule a drain')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
console.log('\n[3] FACT once per settled batch, on a consistent state')
|
|
34
|
+
{
|
|
35
|
+
const s = reactive<{balances: Record<string, number>}>({balances: {BTC: 1, ETH: 10}}, manual)
|
|
36
|
+
let fires = 0, seen = -1
|
|
37
|
+
onUpdate(s.balances, () => { fires++; seen = sum(s.balances) })
|
|
38
|
+
s.balances['BTC'] = 2; s.balances['SOL'] = 3
|
|
39
|
+
ok(fires == 0, 'nothing fires before the drain')
|
|
40
|
+
flush()
|
|
41
|
+
ok(fires == 1, 'one fact for the whole burst: ' + fires)
|
|
42
|
+
ok(seen == 2 + 10 + 3, 'recomputed on the FINAL consistent state: ' + seen)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log('\n[4] the $500 case: replace the WHOLE map, sum unchanged -> no false alarm')
|
|
46
|
+
{
|
|
47
|
+
const s = reactive<{balances: Record<string, number>}>({balances: {BTC: 100, ETH: 400}}, manual)
|
|
48
|
+
const alarms: number[] = []
|
|
49
|
+
onUpdate(s.balances, () => { const t = sum(s.balances); if (t != 500) alarms.push(t) })
|
|
50
|
+
s.balances = {SOL: 250, DOT: 150, ADA: 100}
|
|
51
|
+
flush()
|
|
52
|
+
ok(alarms.length == 0, 'sum stayed 500 across a wholesale replace: ' + JSON.stringify(alarms))
|
|
53
|
+
ok(sum(s.balances) == 500, 'reads the new consistent collection: ' + sum(s.balances))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log('\n[5] replace a node in the MIDDLE; subscriber on it survives + deep writes re-bind')
|
|
57
|
+
{
|
|
58
|
+
const s = reactive<any>({a: {b: {c: {x: 0}}}}, manual)
|
|
59
|
+
let onB = 0
|
|
60
|
+
onUpdate(s.a.b, () => onB++)
|
|
61
|
+
s.a.b = {c: {x: 1}}
|
|
62
|
+
flush()
|
|
63
|
+
ok(onB == 1 && s.a.b.c.x == 1, 'B-subscriber survived wholesale replace: ' + onB)
|
|
64
|
+
s.a.b.c.x = 2
|
|
65
|
+
flush()
|
|
66
|
+
ok(onB == 2 && s.a.b.c.x == 2, 'deep write into replaced sub-tree fires B again: ' + onB)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
console.log('\n[6] watch root = watch everything; delete fires too')
|
|
70
|
+
{
|
|
71
|
+
const s = reactive<any>({a: {b: 1}, list: {k: 1}}, manual)
|
|
72
|
+
let hits = 0
|
|
73
|
+
onUpdate(s, () => hits++)
|
|
74
|
+
s.a.b = 9
|
|
75
|
+
flush(); const after1 = hits
|
|
76
|
+
delete s.list.k
|
|
77
|
+
flush()
|
|
78
|
+
ok(after1 == 1 && hits == 2, 'root subscriber sees deep set AND delete: ' + hits)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log('\n[7] unsubscribe before flush suppresses callback')
|
|
82
|
+
{
|
|
83
|
+
const s = reactive<any>({a: {x: 1}}, manual)
|
|
84
|
+
let hits = 0
|
|
85
|
+
const off = onUpdate(s.a, () => hits++)
|
|
86
|
+
s.a.x = 2
|
|
87
|
+
off()
|
|
88
|
+
flush()
|
|
89
|
+
ok(hits == 0, 'removed subscriber is not called from an already queued batch')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log('\n[8] mutation inside callback re-queues into a follow-up drain')
|
|
93
|
+
{
|
|
94
|
+
const s = reactive({x: 0}, {drain: 'micro'})
|
|
95
|
+
let hits = 0
|
|
96
|
+
onUpdate(s, () => { hits++; if (hits == 1) s.x = 2 })
|
|
97
|
+
s.x = 1
|
|
98
|
+
await flushReactive(s)
|
|
99
|
+
ok(hits == 2 && s.x == 2, 'cascaded update settles without sync recursion: ' + hits)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log('\n[9] arrays are reactive plain containers')
|
|
103
|
+
{
|
|
104
|
+
const s = reactive<any>({arr: [{x: 1}]}, manual)
|
|
105
|
+
let hits = 0
|
|
106
|
+
onUpdate(s.arr, () => hits++)
|
|
107
|
+
s.arr[0].x = 2
|
|
108
|
+
s.arr.push({x: 3})
|
|
109
|
+
flush()
|
|
110
|
+
ok(hits == 1 && s.arr.length == 2 && s.arr[0].x == 2, 'index write + push coalesce on array: ' + hits)
|
|
111
|
+
ok(Array.isArray(s.arr), 'wrapped array still passes Array.isArray')
|
|
112
|
+
ok(JSON.stringify(s.arr) == '[{"x":2},{"x":3}]', 'wrapped array JSON stays array-shaped')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.log('\n[9b] captured array proxy follows object rebind without array invariant throws')
|
|
116
|
+
{
|
|
117
|
+
const s = reactive<any>({arr: [{x: 1}]}, manual)
|
|
118
|
+
const a = s.arr
|
|
119
|
+
s.arr = {x: 1}
|
|
120
|
+
ok(JSON.stringify(a) == '{"x":1}', 'captured array proxy serializes current object target')
|
|
121
|
+
ok(JSON.stringify(Object.keys(a)) == '["x"]', 'Object.keys on rebound array proxy returns object keys')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log('\n[10] Date/Map/class instances are opaque leaves, not proxied')
|
|
125
|
+
{
|
|
126
|
+
class Box { constructor(public x: number) {} inc() { this.x++; return this.x } }
|
|
127
|
+
const d = new Date(1000)
|
|
128
|
+
const m = new Map<string, number>([['a', 1]])
|
|
129
|
+
const b = new Box(1)
|
|
130
|
+
const s = reactive({d, m, b}, manual)
|
|
131
|
+
ok(s.d === d && s.d.getTime() == 1000, 'Date methods keep their native receiver')
|
|
132
|
+
ok(s.m === m && s.m.get('a') == 1, 'Map stays raw')
|
|
133
|
+
ok(s.b === b && s.b.inc() == 2, 'class instance stays raw')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
console.log('\n[11] depth makes deeper objects opaque')
|
|
137
|
+
{
|
|
138
|
+
const s = reactive<any>({a: {b: {x: 1}}}, {...manual, depth: 1})
|
|
139
|
+
let hits = 0
|
|
140
|
+
onUpdate(s.a, () => hits++)
|
|
141
|
+
s.a.b.x = 2
|
|
142
|
+
flush()
|
|
143
|
+
ok(hits == 0, 'deep write below depth is intentionally opaque')
|
|
144
|
+
s.a.b = {x: 3}
|
|
145
|
+
flush()
|
|
146
|
+
ok(hits == 1, 'replace at the wrapped level still fires: ' + hits)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.log('\n[12] callback errors do not stop sibling subscribers')
|
|
150
|
+
{
|
|
151
|
+
const s = reactive({x: 0}, manual)
|
|
152
|
+
let sibling = 0
|
|
153
|
+
let uncaught: any = null
|
|
154
|
+
process.once('uncaughtException', e => { uncaught = e })
|
|
155
|
+
onUpdate(s, () => { throw new Error('boom') })
|
|
156
|
+
onUpdate(s, () => { sibling++ })
|
|
157
|
+
s.x = 1
|
|
158
|
+
flush()
|
|
159
|
+
await tick()
|
|
160
|
+
ok(sibling == 1, 'sibling subscriber still ran after another subscriber threw')
|
|
161
|
+
ok(uncaught?.message == 'boom', 'callback error is re-thrown asynchronously')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
console.log('\n[13] listenUpdate exposes updates as project Listen')
|
|
165
|
+
{
|
|
166
|
+
const s = reactive({a: {b: 1}}, manual)
|
|
167
|
+
pending = null
|
|
168
|
+
const updates = listenUpdate(s.a)
|
|
169
|
+
s.a.b = 2
|
|
170
|
+
ok(pending == null, 'listenUpdate is cold until it has downstream listeners')
|
|
171
|
+
let hits = 0
|
|
172
|
+
const off = updates.on(() => hits++)
|
|
173
|
+
s.a.b = 3
|
|
174
|
+
flush()
|
|
175
|
+
ok(hits == 1, 'listenUpdate fired through on')
|
|
176
|
+
off()
|
|
177
|
+
pending = null
|
|
178
|
+
s.a.b = 4
|
|
179
|
+
ok(pending == null, 'listenUpdate returns cold after last listener leaves')
|
|
180
|
+
flush()
|
|
181
|
+
ok(hits == 1, 'listenUpdate off() removed subscriber')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
console.log('\n[14] descriptor writes go to the reactive target')
|
|
185
|
+
{
|
|
186
|
+
const s = reactive<{x: number; y?: number}>({x: 1}, manual)
|
|
187
|
+
let hits = 0
|
|
188
|
+
onUpdate(s, () => hits++)
|
|
189
|
+
Object.defineProperty(s, 'y', {value: 2, enumerable: true, configurable: true, writable: true})
|
|
190
|
+
flush()
|
|
191
|
+
ok(s.y == 2 && Object.keys(s).includes('y'), 'defineProperty creates visible reactive property')
|
|
192
|
+
ok(hits == 1, 'defineProperty notifies subscribers')
|
|
193
|
+
|
|
194
|
+
const child = reactive({z: 1}, manual)
|
|
195
|
+
Object.defineProperty(s, 'box' as any, {value: child, enumerable: true, configurable: true, writable: true})
|
|
196
|
+
ok((toRaw(s) as any).box === toRaw(child) && (toRaw(s) as any).box !== child, 'defineProperty stores raw values, not reactive proxies')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
console.log('\n[15] deleted child proxies do not bubble into the live tree')
|
|
200
|
+
{
|
|
201
|
+
const s = reactive<{a?: {x: number}}>({a: {x: 1}}, manual)
|
|
202
|
+
const oldA = s.a!
|
|
203
|
+
let root = 0, child = 0
|
|
204
|
+
onUpdate(s, () => root++)
|
|
205
|
+
onUpdate(oldA, () => child++)
|
|
206
|
+
delete s.a
|
|
207
|
+
flush()
|
|
208
|
+
ok(root == 1 && child == 1 && !('a' in s), 'delete notifies root and deleted child once')
|
|
209
|
+
oldA.x = 2
|
|
210
|
+
flush()
|
|
211
|
+
ok(root == 1 && child == 1, 'mutating captured deleted child is detached')
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
console.log('\n[16] duplicate onUpdate callbacks are independent subscriptions')
|
|
215
|
+
{
|
|
216
|
+
const s = reactive({x: 0}, manual)
|
|
217
|
+
let hits = 0
|
|
218
|
+
const cb = () => hits++
|
|
219
|
+
const off1 = onUpdate(s, cb)
|
|
220
|
+
const off2 = onUpdate(s, cb)
|
|
221
|
+
off1()
|
|
222
|
+
s.x = 1
|
|
223
|
+
flush()
|
|
224
|
+
ok(hits == 1, 'second duplicate subscription remains after first off')
|
|
225
|
+
off2()
|
|
226
|
+
s.x = 2
|
|
227
|
+
flush()
|
|
228
|
+
ok(hits == 1, 'second off removes remaining duplicate subscription')
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
console.log('\n[17] optional dirty paths are relative to the subscribed node')
|
|
232
|
+
{
|
|
233
|
+
const s = reactive<any>({strategies: {a: {status: false}}, rows: [1]}, manual)
|
|
234
|
+
const got: string[][] = []
|
|
235
|
+
onUpdatePaths(s.strategies, change => got.push(change.paths.map(p => p.join('.')).sort()))
|
|
236
|
+
s.strategies.a.status = true
|
|
237
|
+
flush()
|
|
238
|
+
s.strategies.b = {status: false}
|
|
239
|
+
flush()
|
|
240
|
+
delete s.strategies.a
|
|
241
|
+
flush()
|
|
242
|
+
ok(JSON.stringify(got) == '[["a.status"],["b"],["a"]]', 'object set/add/delete paths: ' + JSON.stringify(got))
|
|
243
|
+
|
|
244
|
+
const rootGot: string[][] = []
|
|
245
|
+
onUpdatePaths(s, change => rootGot.push(change.paths.map(p => p.join('.')).sort()))
|
|
246
|
+
s.rows.push(2)
|
|
247
|
+
flush()
|
|
248
|
+
ok(JSON.stringify(rootGot) == '[["rows"]]', 'array mutation dirties the array branch, not splice internals: ' + JSON.stringify(rootGot))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
console.log('\n[18] listenUpdatePaths exposes dirty paths as a cold Listen')
|
|
252
|
+
{
|
|
253
|
+
const s = reactive({a: {b: 1}}, manual)
|
|
254
|
+
pending = null
|
|
255
|
+
const updates = listenUpdatePaths(s.a)
|
|
256
|
+
s.a.b = 2
|
|
257
|
+
ok(pending == null, 'listenUpdatePaths is cold until it has downstream listeners')
|
|
258
|
+
let got = ''
|
|
259
|
+
const off = updates.on(change => { got = change.paths.map(p => p.join('.')).join(',') })
|
|
260
|
+
s.a.b = 3
|
|
261
|
+
flush()
|
|
262
|
+
ok(got == 'b', 'listenUpdatePaths fired with relative dirty path: ' + got)
|
|
263
|
+
off()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
console.log(`\n${fails == 0 ? 'ALL GREEN' : fails + ' FAILURE(S)'}`)
|
|
267
|
+
process.exit(fails == 0 ? 0 : 1)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
main().catch(e => { console.error(e); process.exit(1) })
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '../src/Common/Observe/reactive';
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createMemoryOfflineStorage,
|
|
3
|
+
createStore,
|
|
4
|
+
createStoreManager,
|
|
5
|
+
exposeStore,
|
|
6
|
+
exposeStoreReplay,
|
|
7
|
+
flushReactive,
|
|
8
|
+
managedStore,
|
|
9
|
+
} from '../src/Common/Observe'
|
|
10
|
+
|
|
11
|
+
let fails = 0
|
|
12
|
+
const ok = (condition: any, message: string) => {
|
|
13
|
+
if (!condition) { fails++; console.log(' FAIL', message) }
|
|
14
|
+
else console.log(' OK ', message)
|
|
15
|
+
}
|
|
16
|
+
const tick = () => new Promise<void>(resolve => setTimeout(resolve, 0))
|
|
17
|
+
const settle = async (state: object) => { await flushReactive(state); await tick(); await tick() }
|
|
18
|
+
const json = (v: any) => JSON.stringify(v)
|
|
19
|
+
|
|
20
|
+
type Market = {
|
|
21
|
+
data: {BTC?: number; ETH?: number}
|
|
22
|
+
meta: {status?: string}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Rows = {
|
|
26
|
+
rows: Record<string, {qty: number}>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function main() {
|
|
30
|
+
console.log('\n[store-manager] plan gates and mirror sync')
|
|
31
|
+
{
|
|
32
|
+
const server = createStore<Market>({data: {BTC: 1, ETH: 2}, meta: {status: 'ok'}}, {drain: 'micro'})
|
|
33
|
+
const remote = exposeStore(server)
|
|
34
|
+
const manager = createStoreManager({
|
|
35
|
+
market: managedStore.mirror({
|
|
36
|
+
remote,
|
|
37
|
+
initial: {data: {}, meta: {}},
|
|
38
|
+
mask: {data: {BTC: true}, meta: {status: true}},
|
|
39
|
+
priority: 10,
|
|
40
|
+
tags: ['route:main', 'bootstrap'],
|
|
41
|
+
sync: {opts: {current: true, drain: 'micro'}},
|
|
42
|
+
storeOpts: {drain: 'micro'},
|
|
43
|
+
}),
|
|
44
|
+
heavyHistory: managedStore.mirror({
|
|
45
|
+
remote,
|
|
46
|
+
initial: {data: {}, meta: {}},
|
|
47
|
+
mask: true,
|
|
48
|
+
priority: 1000,
|
|
49
|
+
tags: ['history'],
|
|
50
|
+
explicitOnly: true,
|
|
51
|
+
large: true,
|
|
52
|
+
}),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const defaultPlan = manager.plan()
|
|
56
|
+
ok(defaultPlan.length == 1 && defaultPlan[0].key == 'market', 'default plan excludes large explicit-only resources')
|
|
57
|
+
const fullPlan = manager.plan({includeExplicit: true, includeLarge: true})
|
|
58
|
+
ok(fullPlan[0].key == 'heavyHistory', 'full plan can include high-priority explicit resource')
|
|
59
|
+
|
|
60
|
+
let rejected = false
|
|
61
|
+
try { await manager.start('heavyHistory') }
|
|
62
|
+
catch { rejected = true }
|
|
63
|
+
ok(rejected, 'explicitOnly resource rejects implicit start')
|
|
64
|
+
|
|
65
|
+
const mirror = await manager.start('market')
|
|
66
|
+
ok(mirror.state.data.BTC == 1 && mirror.state.data.ETH === undefined && mirror.state.meta.status == 'ok', 'mirror starts with selected mask only')
|
|
67
|
+
|
|
68
|
+
server.state.data.BTC = 5
|
|
69
|
+
server.state.data.ETH = 9
|
|
70
|
+
server.state.meta.status = 'warn'
|
|
71
|
+
await settle(server.state)
|
|
72
|
+
ok(mirror.state.data.BTC == 5 && mirror.state.data.ETH === undefined && mirror.state.meta.status == 'warn', 'mirror keeps syncing selected paths')
|
|
73
|
+
|
|
74
|
+
manager.touch('market', 3)
|
|
75
|
+
const usage = manager.usage().get('market')
|
|
76
|
+
ok(usage?.count == 1 && usage.weight == 3, 'touch records usage weight for planning')
|
|
77
|
+
|
|
78
|
+
manager.stop('market')
|
|
79
|
+
ok(manager.handles.market.status().state == 'stopped', 'stop updates handle status')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log('\n[store-manager] offline replay resource')
|
|
83
|
+
{
|
|
84
|
+
const backend = createStore<Rows>({rows: {a: {qty: 1}}}, {drain: 'micro'})
|
|
85
|
+
const exposed = exposeStoreReplay(backend, {history: 100})
|
|
86
|
+
const storage = createMemoryOfflineStorage()
|
|
87
|
+
const manager = createStoreManager({
|
|
88
|
+
rows: managedStore.offline<Rows>({
|
|
89
|
+
remote: exposed.api.replay,
|
|
90
|
+
initial: {rows: {}},
|
|
91
|
+
storage,
|
|
92
|
+
debounceMs: 0,
|
|
93
|
+
storeOpts: {drain: 'micro'},
|
|
94
|
+
tags: ['route:rows'],
|
|
95
|
+
}),
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const store = await manager.start('rows')
|
|
99
|
+
ok(json(store.state) == json(backend.snapshot()), 'offline resource starts from replay keyframe')
|
|
100
|
+
|
|
101
|
+
backend.state.rows.b = {qty: 2}
|
|
102
|
+
await settle(backend.state)
|
|
103
|
+
ok(store.state.rows.b.qty == 2, 'offline resource stays connected to replay updates')
|
|
104
|
+
|
|
105
|
+
await store.flush()
|
|
106
|
+
const saved = await storage.read<any>('rows')
|
|
107
|
+
ok(saved?.seq != null && saved.snapshot.rows.b.qty == 2, 'offline resource persists snapshot and seq')
|
|
108
|
+
|
|
109
|
+
manager.stopAll()
|
|
110
|
+
exposed.close()
|
|
111
|
+
ok(manager.handles.rows.status().state == 'stopped', 'stopAll closes offline resource')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(`\n${fails == 0 ? 'ALL GREEN' : fails + ' FAILURE(S)'}`)
|
|
115
|
+
process.exit(fails == 0 ? 0 : 1)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
main().catch(e => { console.error(e); process.exit(1) })
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Run:
|
|
2
|
+
// npx tsx observe/store-mirror.example.ts
|
|
3
|
+
|
|
4
|
+
import {createStore, createStoreMirror, exposeStore} from './store'
|
|
5
|
+
import {flushReactive} from './reactive'
|
|
6
|
+
|
|
7
|
+
type SymbolRef = {exchange: string; category: string; symbol: string}
|
|
8
|
+
type Strategy = {
|
|
9
|
+
name: string
|
|
10
|
+
strategyName: string
|
|
11
|
+
symbols: SymbolRef[]
|
|
12
|
+
params: any
|
|
13
|
+
paramsRun: any
|
|
14
|
+
status: boolean
|
|
15
|
+
show: boolean
|
|
16
|
+
}
|
|
17
|
+
type StrategyStore = {strategies: Record<string, Strategy>}
|
|
18
|
+
|
|
19
|
+
const tick = () => new Promise<void>(resolve => setTimeout(resolve, 0))
|
|
20
|
+
const keys = (store: StrategyStore) => Object.keys(store.strategies).sort().join(',') || '-'
|
|
21
|
+
|
|
22
|
+
async function settle(state: object) {
|
|
23
|
+
await flushReactive(state)
|
|
24
|
+
await tick()
|
|
25
|
+
await tick()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function main() {
|
|
29
|
+
// Backend: ordinary mutable control state.
|
|
30
|
+
const backend = createStore<StrategyStore>({strategies: {}}, {drain: 'micro'})
|
|
31
|
+
|
|
32
|
+
// API shape: get/set/replace + changed + changedPaths.
|
|
33
|
+
// Over RPC the frontend receives the same shape from the SDK client.
|
|
34
|
+
const exposed = exposeStore(backend)
|
|
35
|
+
const api = {
|
|
36
|
+
...exposed,
|
|
37
|
+
get(mask?: any) {
|
|
38
|
+
console.log('pull', JSON.stringify(mask))
|
|
39
|
+
return exposed.get(mask)
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Frontend: local mirror. UI subscribes locally, not to the network stream.
|
|
44
|
+
const mirror = createStoreMirror<StrategyStore>(api, {strategies: {}}, {drain: 'micro'})
|
|
45
|
+
const stopSync = await mirror.sync({strategies: true}, {current: true, drain: 'micro'})
|
|
46
|
+
const stopUi = mirror.node.strategies.on(() => {
|
|
47
|
+
console.log('ui strategies:', keys(mirror.state))
|
|
48
|
+
}, {current: true, drain: 'micro'})
|
|
49
|
+
|
|
50
|
+
backend.state.strategies.alpha = {
|
|
51
|
+
name: 'alpha',
|
|
52
|
+
strategyName: 'meanReversion',
|
|
53
|
+
symbols: [{exchange: 'binance', category: 'spot', symbol: 'BTCUSDT'}],
|
|
54
|
+
params: {period: 20},
|
|
55
|
+
paramsRun: {},
|
|
56
|
+
status: false,
|
|
57
|
+
show: true,
|
|
58
|
+
}
|
|
59
|
+
await settle(backend.state)
|
|
60
|
+
|
|
61
|
+
backend.state.strategies.alpha.status = true
|
|
62
|
+
await settle(backend.state)
|
|
63
|
+
|
|
64
|
+
delete backend.state.strategies.alpha
|
|
65
|
+
await settle(backend.state)
|
|
66
|
+
|
|
67
|
+
stopUi()
|
|
68
|
+
stopSync()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
main().catch(e => {
|
|
72
|
+
console.error(e)
|
|
73
|
+
process.exit(1)
|
|
74
|
+
})
|