wenay-common2 1.0.74 → 1.0.76
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/doc/ROADMAP.md +7 -6
- package/doc/changes/1.0.75.md +10 -0
- package/doc/changes/1.0.76.md +4 -0
- package/doc/wenay-common2-rare.md +38 -16
- package/doc/wenay-common2.md +438 -427
- package/lib/Common/events/replay-channel.js +55 -6
- package/lib/Common/events/replay-route.js +5 -2
- package/lib/Common/events/replay-wire.js +137 -47
- package/lib/Common/events/transport-lifecycle.d.ts +23 -0
- package/lib/Common/events/transport-lifecycle.js +94 -0
- package/lib/Common/rcp/rpc-client.js +346 -111
- package/lib/Common/rcp/rpc-clientHub.d.ts +2 -0
- package/lib/Common/rcp/rpc-clientHub.js +120 -25
- package/lib/Common/rcp/rpc-server.js +33 -9
- package/observe/store-manager.test.ts +43 -1
- package/oracle/realsocket/lifecycle.spec.ts +3 -3
- package/oracle/realsocket/replay-reconnect-auth.spec.ts +111 -0
- package/oracle/realsocket/replay-reconnect.spec.ts +592 -0
- package/package.json +1 -1
- package/replay/route-webrtc.test.ts +15 -2
- package/rpc.md +19 -4
- package/doc/changes/1.0.65.md +0 -8
- package/doc/changes/1.0.66.md +0 -8
package/doc/wenay-common2.md
CHANGED
|
@@ -1,430 +1,441 @@
|
|
|
1
|
-
# wenay-common2 — BRIEF cheat sheet (notation)
|
|
2
|
-
|
|
3
|
-
> Root import: `import { ... } from "wenay-common2"`.
|
|
4
|
-
> Notation: `name(args: types) -> ret // note`. Types are shown where they decide a correct call (callback shape,
|
|
5
|
-
> overloads, return). Short names are **canonical**; removed old names are listed in `NAMING_RENAMES.md`.
|
|
6
|
-
> Full surface → **`wenay-common2-rare.md`**. Code style → `CLAUDE.md`. Full RPC guide → `rpc.md`.
|
|
7
|
-
|
|
8
|
-
## ⭐ events — `listen` / `listenStore`
|
|
9
|
-
```
|
|
10
|
-
import { listen, listenStore, mapListen } from "wenay-common2"
|
|
11
|
-
|
|
12
|
-
listen<T>(opts?) -> [emit, listen] // pure event list: no local value storage, no current replay
|
|
13
|
-
emit(...args: T) // dispatch event only
|
|
14
|
-
listen.on(cb: (...args: T) => void, {key?, cbClose?}) -> off
|
|
15
|
-
listen.once(cb, {key?}) -> off // one future event
|
|
16
|
-
listen.onClose(cb: () => void) -> off
|
|
17
|
-
listen.close() // clear listeners + fire close hooks + teardown producer
|
|
18
|
-
listen.count() -> number
|
|
19
|
-
|
|
20
|
-
listenStore<T>({current, ...opts}) -> [emit, listen]
|
|
21
|
-
// store wrapper: current() reads external store by reference; the listener does not keep its own value copy
|
|
22
|
-
listen.on(cb, {current: true}) -> off // current store value first, then future events
|
|
23
|
-
listen.on(cb) -> off // future events only
|
|
24
|
-
listen.once(cb, {current: true}) -> off // one value from current store, if present; otherwise waits future event
|
|
25
|
-
listen.once(cb) -> off // one future event
|
|
26
|
-
listen.on(cb, {current: () => argsOrUndefined}) -> off
|
|
27
|
-
|
|
28
|
-
opts: { fast? = true, event?(t: 'add'|'remove', count, api), closeOn? }
|
|
29
|
-
```
|
|
30
|
-
```
|
|
31
|
-
mapListen<TIn, TOut>(src, map: (...a: TIn) => TOut | null, opts?) -> [emit, listen] // map+filter (null skips); lazy subscribe
|
|
32
|
-
joinListens(listens | ports, keyExtractor?) -> { listen, add(port, key?), pending: number, clear(tid?) } // zip by key
|
|
33
|
-
```
|
|
34
|
-
## ⭐ sleep
|
|
35
|
-
```
|
|
36
|
-
sleepAsync(ms = 0) -> Promise<void>
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
## ⏱️ async
|
|
40
|
-
```
|
|
41
|
-
createThrottle() -> { throttle(ms: number, fn: () => void) -> void,
|
|
42
|
-
debounce(ms: number, fn: () => void) -> Promise<void> }
|
|
43
|
-
// fn is a ZERO-ARG thunk the scheduler runs itself; NOT a lodash-style wrapper that returns a callable
|
|
44
|
-
// ONE createThrottle() = ONE shared limiter (shared busy/pending) — use a SEPARATE instance per operation;
|
|
45
|
-
// throttle + debounce on the SAME instance contend (a busy throttle silently drops the debounce's trailing run)
|
|
46
|
-
createAsyncQueue(concurrency = 1) -> { add<R>(task: () => Promise<R>) -> Promise<R>, onIdle() -> Promise<void>, size: number } // p-queue
|
|
47
|
-
createReadyGate() -> { add(fn: () => void), ready() } // buffer fns until ready(), then run them in order
|
|
48
|
-
promiseProgress<T>(arr: (Promise<T> | (() => Promise<T>))[]) -> {
|
|
49
|
-
onOk(cb), onError(cb), all() -> Promise<any[]>, allSettled(), items(), stats() -> { ok: number, error: number, count: number } }
|
|
50
|
-
// factory entries start on .all()/.allSettled()/items() (once); .all() rejects like Promise.all — read aggregate progress via stats()
|
|
51
|
-
// alias: enhancedWaitRun->createThrottle · createTaskQueue->createReadyGate(.setReady->.ready) · createAsyncQueue.enqueue->add · .getQueueSize->size
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
## 🧰 core — clone / compare
|
|
55
|
-
```
|
|
56
|
-
clone<T>(v: T) -> T // deep: cycles + Map/Set/Date, rebinds functions (alias: deepClone)
|
|
57
|
-
shallowClone<T>(v: T) -> T
|
|
58
|
-
isEqual(a, b) -> boolean // PLAIN object/array trees ONLY; on Map/Set/Date it returns a vacuous `true` — don't use it on them (alias: deepEqual)
|
|
59
|
-
shallowEqual(a, b) · arrayShallowEqual(a, b) -> boolean // strict-by-key vs loose-by-index — both kept on purpose
|
|
60
|
-
toImmutable<T>(o: T) -> T // deep-frozen clone + Mutable:false marker
|
|
61
|
-
JSON_clone<T>(o: T) -> T // JSON round-trip; DROPS Map/Set (-> {}) and turns Date -> string. NOT a rich clone — use clone() for those
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
## 🧰 core — binary search / maps / mutex / memo
|
|
65
|
-
```
|
|
66
|
-
BSearch<T>(arr: ArrayLike<T>, value: T | comparer, match?: 'equal'|'lessOrEqual'|'greatOrEqual', sort?: SortMode) -> number
|
|
67
|
-
// comparer is (item: T) => number OR (a: T, b) => number; without it, T must have valueOf():number
|
|
68
|
-
BSearchNearest<T>(arr: ArrayLike<T>, value: number, getter?: (el: T) => number, maxDelta?: number) -> number
|
|
69
|
-
new MapExt<K,V>() / new WeakMapExt<K,V>(): .getOrSet(k, () => v) -> V // lazy insert (a plain value is also accepted)
|
|
70
|
-
new Mutex(): .runExclusive<T>(fn: () => T | Promise<T>) -> Promise<T> | .lock() -> Promise<release: () => void> // runExclusive: was dispatch
|
|
71
|
-
MemoFunc<A extends any[], R>(opts?: { timeDelta?: number, maxLimits?: number }) -> { func(...a: A) -> R, cleanAll(), memo } // TTL + LRU; per-call {timeDelta, reSave}
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
## 🔢 core — number (frequent)
|
|
75
|
-
```
|
|
76
|
-
round(value: number, digits = 0) -> number // round to N decimals (alias: NormalizeDouble)
|
|
77
|
-
roundSig(value: number, { digitsR?: number /*total significant digits*/, digitsPoint? = 4, type?: 'max'|'min' }) -> number
|
|
78
|
-
// round to N significant digits — roundSig(1234.5678, {digitsR: 3}) -> 1230 (alias: NormalizeDoubleAnd)
|
|
79
|
-
gcd(a: number, b: number, digits = 8) -> number | gcd(values: Iterable<number>, digits = 8) -> number
|
|
80
|
-
// floats ok; in the ITERABLE overload the 2nd arg is PRECISION digits, not an operand (alias: MaxCommonDivisor[OnArray])
|
|
81
|
-
formatAuto(value: number, maxDigits = 8) -> string // shortest decimal string (alias: DblToStrAuto)
|
|
82
|
-
decimals(value: number, maxDigits = 8, minDigits = 0) -> number // count of meaningful decimals (alias: GetDblPrecision)
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
## ⏰ time
|
|
86
|
-
```
|
|
87
|
-
format(date: Date, pattern, { utc? = true }) -> string // (alias of 11 timeToStr_*/timeLocalToStr_*)
|
|
88
|
-
pattern: 'HH:mm:ss' | 'HH:mm:ss.SSS' | 'yyyy-MM-dd'
|
|
89
|
-
| 'yyyy-MM-dd HH:mm' | 'yyyy-MM-dd HH:mm:ss' | 'yyyy-MM-dd HH:mm:ss.SSS'
|
|
90
|
-
| 'yyyy-MM-dd HH:mm O' | 'yyyy-MM-dd HH:mm:ss O' // O = GMT offset (local)
|
|
91
|
-
{ utc: false } -> local variants
|
|
92
|
-
formatDuration(ms: number, pattern?: 'H:mm:ss' | 'H:mm:ss.SSS') -> string // clock; hours unbounded
|
|
93
|
-
durationToStr(ms: number) -> string // humanized
|
|
94
|
-
minDate(a: Date | null, b: Date | null) / maxDate(a, b) -> Date | null // null-tolerant (alias: MinTime/MaxTime)
|
|
95
|
-
convertDatesToStrings(obj) // Date -> strings, recursively (logs)
|
|
96
|
-
const: H1_S D1_S W1_S · M1_MS H1_MS D1_MS W1_MS
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
## 🌐 rpc (brief) — transport is ALWAYS caller-supplied (`{emit,on}`); there is NO url / built-in socket
|
|
100
|
-
```
|
|
101
|
-
// SERVER: `object` is the impl tree, `socket` is a {emit,on} transport adapter
|
|
102
|
-
createRpcServerAuto({ socket: {emit, on}, object, socketKey: string, auth?, limits?, maxPerListen?, throttle?, opt?, replay?, replayOpts? }) -> { api, ... }
|
|
103
|
-
// replay: false|'auto' (default)|'force' — facade members that are replay lines (replayListen) are exposed
|
|
104
|
-
// with BOTH surfaces under the SAME key: legacy plain-Listen path byte-for-byte + line/frameLine/since/keyframe/frame.
|
|
105
|
-
// Upgrading listen -> replayListen is a declaration-site-only change; the facade and clients don't move.
|
|
106
|
-
// replayOpts: {pending?, highWater?, lowWater?, pollMs?} — per-connection lag gate for 'frame'-policy subscribers
|
|
107
|
-
// (pending defaults to socket.io writeBuffer; gates close on disconnect automatically). Replay lines are never throttled.
|
|
108
|
-
createRpcServer(opts) // lower-level core (same { socket, object, socketKey })
|
|
109
|
-
noStrict(obj) // mark a dynamic subtree (no schema)
|
|
110
|
-
endCallback(fn) // mark an RPC stream-callback's end (alias: rpcEndCallback)
|
|
111
|
-
|
|
112
|
-
// CLIENT hub: takes TWO functions — a socket factory + a schema builder; it is NOT an {url} or an options bag
|
|
113
|
-
createRpcClientHub(
|
|
114
|
-
createSocket: (token: string | null) => socket, // YOU build the socket, e.g. socket.io io(url, {auth:{token}})
|
|
115
|
-
schemaBuilder: (rpc) => ({ key: rpc<Api>('socketKey') }), // declare each socketKey's typed API
|
|
116
|
-
hubOpts?: { opt? },
|
|
117
|
-
) -> hub
|
|
118
|
-
hub: connect(token) -> Promise<clients> · reauth(token) · facade · promise · socket · onConnect/onDisconnect // connect: was setToken
|
|
119
|
-
// connect()
|
|
120
|
-
client (on clients[key], NOT on the hub): func (proxy) · strict (schema-safe) · close() · ready() · init() · subscriptions()
|
|
121
|
-
// pipe = batch a server chain in one packet · space = fire-and-forget
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
Media
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
Media.
|
|
157
|
-
|
|
158
|
-
Media.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
me.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
1
|
+
# wenay-common2 — BRIEF cheat sheet (notation)
|
|
2
|
+
|
|
3
|
+
> Root import: `import { ... } from "wenay-common2"`.
|
|
4
|
+
> Notation: `name(args: types) -> ret // note`. Types are shown where they decide a correct call (callback shape,
|
|
5
|
+
> overloads, return). Short names are **canonical**; removed old names are listed in `NAMING_RENAMES.md`.
|
|
6
|
+
> Full surface → **`wenay-common2-rare.md`**. Code style → `CLAUDE.md`. Full RPC guide → `rpc.md`.
|
|
7
|
+
|
|
8
|
+
## ⭐ events — `listen` / `listenStore`
|
|
9
|
+
```
|
|
10
|
+
import { listen, listenStore, mapListen } from "wenay-common2"
|
|
11
|
+
|
|
12
|
+
listen<T>(opts?) -> [emit, listen] // pure event list: no local value storage, no current replay
|
|
13
|
+
emit(...args: T) // dispatch event only
|
|
14
|
+
listen.on(cb: (...args: T) => void, {key?, cbClose?}) -> off
|
|
15
|
+
listen.once(cb, {key?}) -> off // one future event
|
|
16
|
+
listen.onClose(cb: () => void) -> off
|
|
17
|
+
listen.close() // clear listeners + fire close hooks + teardown producer
|
|
18
|
+
listen.count() -> number
|
|
19
|
+
|
|
20
|
+
listenStore<T>({current, ...opts}) -> [emit, listen]
|
|
21
|
+
// store wrapper: current() reads external store by reference; the listener does not keep its own value copy
|
|
22
|
+
listen.on(cb, {current: true}) -> off // current store value first, then future events
|
|
23
|
+
listen.on(cb) -> off // future events only
|
|
24
|
+
listen.once(cb, {current: true}) -> off // one value from current store, if present; otherwise waits future event
|
|
25
|
+
listen.once(cb) -> off // one future event
|
|
26
|
+
listen.on(cb, {current: () => argsOrUndefined}) -> off
|
|
27
|
+
|
|
28
|
+
opts: { fast? = true, event?(t: 'add'|'remove', count, api), closeOn? }
|
|
29
|
+
```
|
|
30
|
+
```
|
|
31
|
+
mapListen<TIn, TOut>(src, map: (...a: TIn) => TOut | null, opts?) -> [emit, listen] // map+filter (null skips); lazy subscribe
|
|
32
|
+
joinListens(listens | ports, keyExtractor?) -> { listen, add(port, key?), pending: number, clear(tid?) } // zip by key
|
|
33
|
+
```
|
|
34
|
+
## ⭐ sleep
|
|
35
|
+
```
|
|
36
|
+
sleepAsync(ms = 0) -> Promise<void>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## ⏱️ async
|
|
40
|
+
```
|
|
41
|
+
createThrottle() -> { throttle(ms: number, fn: () => void) -> void,
|
|
42
|
+
debounce(ms: number, fn: () => void) -> Promise<void> }
|
|
43
|
+
// fn is a ZERO-ARG thunk the scheduler runs itself; NOT a lodash-style wrapper that returns a callable
|
|
44
|
+
// ONE createThrottle() = ONE shared limiter (shared busy/pending) — use a SEPARATE instance per operation;
|
|
45
|
+
// throttle + debounce on the SAME instance contend (a busy throttle silently drops the debounce's trailing run)
|
|
46
|
+
createAsyncQueue(concurrency = 1) -> { add<R>(task: () => Promise<R>) -> Promise<R>, onIdle() -> Promise<void>, size: number } // p-queue
|
|
47
|
+
createReadyGate() -> { add(fn: () => void), ready() } // buffer fns until ready(), then run them in order
|
|
48
|
+
promiseProgress<T>(arr: (Promise<T> | (() => Promise<T>))[]) -> {
|
|
49
|
+
onOk(cb), onError(cb), all() -> Promise<any[]>, allSettled(), items(), stats() -> { ok: number, error: number, count: number } }
|
|
50
|
+
// factory entries start on .all()/.allSettled()/items() (once); .all() rejects like Promise.all — read aggregate progress via stats()
|
|
51
|
+
// alias: enhancedWaitRun->createThrottle · createTaskQueue->createReadyGate(.setReady->.ready) · createAsyncQueue.enqueue->add · .getQueueSize->size
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## 🧰 core — clone / compare
|
|
55
|
+
```
|
|
56
|
+
clone<T>(v: T) -> T // deep: cycles + Map/Set/Date, rebinds functions (alias: deepClone)
|
|
57
|
+
shallowClone<T>(v: T) -> T
|
|
58
|
+
isEqual(a, b) -> boolean // PLAIN object/array trees ONLY; on Map/Set/Date it returns a vacuous `true` — don't use it on them (alias: deepEqual)
|
|
59
|
+
shallowEqual(a, b) · arrayShallowEqual(a, b) -> boolean // strict-by-key vs loose-by-index — both kept on purpose
|
|
60
|
+
toImmutable<T>(o: T) -> T // deep-frozen clone + Mutable:false marker
|
|
61
|
+
JSON_clone<T>(o: T) -> T // JSON round-trip; DROPS Map/Set (-> {}) and turns Date -> string. NOT a rich clone — use clone() for those
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 🧰 core — binary search / maps / mutex / memo
|
|
65
|
+
```
|
|
66
|
+
BSearch<T>(arr: ArrayLike<T>, value: T | comparer, match?: 'equal'|'lessOrEqual'|'greatOrEqual', sort?: SortMode) -> number
|
|
67
|
+
// comparer is (item: T) => number OR (a: T, b) => number; without it, T must have valueOf():number
|
|
68
|
+
BSearchNearest<T>(arr: ArrayLike<T>, value: number, getter?: (el: T) => number, maxDelta?: number) -> number
|
|
69
|
+
new MapExt<K,V>() / new WeakMapExt<K,V>(): .getOrSet(k, () => v) -> V // lazy insert (a plain value is also accepted)
|
|
70
|
+
new Mutex(): .runExclusive<T>(fn: () => T | Promise<T>) -> Promise<T> | .lock() -> Promise<release: () => void> // runExclusive: was dispatch
|
|
71
|
+
MemoFunc<A extends any[], R>(opts?: { timeDelta?: number, maxLimits?: number }) -> { func(...a: A) -> R, cleanAll(), memo } // TTL + LRU; per-call {timeDelta, reSave}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## 🔢 core — number (frequent)
|
|
75
|
+
```
|
|
76
|
+
round(value: number, digits = 0) -> number // round to N decimals (alias: NormalizeDouble)
|
|
77
|
+
roundSig(value: number, { digitsR?: number /*total significant digits*/, digitsPoint? = 4, type?: 'max'|'min' }) -> number
|
|
78
|
+
// round to N significant digits — roundSig(1234.5678, {digitsR: 3}) -> 1230 (alias: NormalizeDoubleAnd)
|
|
79
|
+
gcd(a: number, b: number, digits = 8) -> number | gcd(values: Iterable<number>, digits = 8) -> number
|
|
80
|
+
// floats ok; in the ITERABLE overload the 2nd arg is PRECISION digits, not an operand (alias: MaxCommonDivisor[OnArray])
|
|
81
|
+
formatAuto(value: number, maxDigits = 8) -> string // shortest decimal string (alias: DblToStrAuto)
|
|
82
|
+
decimals(value: number, maxDigits = 8, minDigits = 0) -> number // count of meaningful decimals (alias: GetDblPrecision)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## ⏰ time
|
|
86
|
+
```
|
|
87
|
+
format(date: Date, pattern, { utc? = true }) -> string // (alias of 11 timeToStr_*/timeLocalToStr_*)
|
|
88
|
+
pattern: 'HH:mm:ss' | 'HH:mm:ss.SSS' | 'yyyy-MM-dd'
|
|
89
|
+
| 'yyyy-MM-dd HH:mm' | 'yyyy-MM-dd HH:mm:ss' | 'yyyy-MM-dd HH:mm:ss.SSS'
|
|
90
|
+
| 'yyyy-MM-dd HH:mm O' | 'yyyy-MM-dd HH:mm:ss O' // O = GMT offset (local)
|
|
91
|
+
{ utc: false } -> local variants
|
|
92
|
+
formatDuration(ms: number, pattern?: 'H:mm:ss' | 'H:mm:ss.SSS') -> string // clock; hours unbounded
|
|
93
|
+
durationToStr(ms: number) -> string // humanized
|
|
94
|
+
minDate(a: Date | null, b: Date | null) / maxDate(a, b) -> Date | null // null-tolerant (alias: MinTime/MaxTime)
|
|
95
|
+
convertDatesToStrings(obj) // Date -> strings, recursively (logs)
|
|
96
|
+
const: H1_S D1_S W1_S · M1_MS H1_MS D1_MS W1_MS
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## 🌐 rpc (brief) — transport is ALWAYS caller-supplied (`{emit,on}`); there is NO url / built-in socket
|
|
100
|
+
```
|
|
101
|
+
// SERVER: `object` is the impl tree, `socket` is a {emit,on} transport adapter
|
|
102
|
+
createRpcServerAuto({ socket: {emit, on}, object, socketKey: string, auth?, limits?, maxPerListen?, throttle?, opt?, replay?, replayOpts? }) -> { api, ... }
|
|
103
|
+
// replay: false|'auto' (default)|'force' — facade members that are replay lines (replayListen) are exposed
|
|
104
|
+
// with BOTH surfaces under the SAME key: legacy plain-Listen path byte-for-byte + line/frameLine/since/keyframe/frame.
|
|
105
|
+
// Upgrading listen -> replayListen is a declaration-site-only change; the facade and clients don't move.
|
|
106
|
+
// replayOpts: {pending?, highWater?, lowWater?, pollMs?} — per-connection lag gate for 'frame'-policy subscribers
|
|
107
|
+
// (pending defaults to socket.io writeBuffer; gates close on disconnect automatically). Replay lines are never throttled.
|
|
108
|
+
createRpcServer(opts) // lower-level core (same { socket, object, socketKey })
|
|
109
|
+
noStrict(obj) // mark a dynamic subtree (no schema)
|
|
110
|
+
endCallback(fn) // mark an RPC stream-callback's end (alias: rpcEndCallback)
|
|
111
|
+
|
|
112
|
+
// CLIENT hub: takes TWO functions — a socket factory + a schema builder; it is NOT an {url} or an options bag
|
|
113
|
+
createRpcClientHub(
|
|
114
|
+
createSocket: (token: string | null) => socket, // YOU build the socket, e.g. socket.io io(url, {auth:{token}})
|
|
115
|
+
schemaBuilder: (rpc) => ({ key: rpc<Api>('socketKey') }), // declare each socketKey's typed API
|
|
116
|
+
hubOpts?: { opt? },
|
|
117
|
+
) -> hub
|
|
118
|
+
hub: connect(token) -> Promise<clients> · reauth(token) · facade · promise · socket · onConnect/onDisconnect · connectListen/disconnectListen // connect: was setToken
|
|
119
|
+
// connect() resolves after the socket 'connect' event and RPC route/auth handshake; for in-proc/loopback (no 'connect') use hub.facade + await hub.promise
|
|
120
|
+
client (on clients[key], NOT on the hub): func (proxy) · strict (schema-safe) · close() · ready() · init() · subscriptions()
|
|
121
|
+
// pipe = batch a server chain in one packet · space = fire-and-forget
|
|
122
|
+
// func/space/strict paths have stable identity per client+surface: c.func.a.b === c.func.a.b;
|
|
123
|
+
// all === func. Identity survives reauth/transient reconnect; connect/setToken creates a new client generation.
|
|
124
|
+
// onConnect/onDisconnect keep their legacy single-slot contract; connectListen(cb)/disconnectListen(cb) are additive and return individual off functions.
|
|
125
|
+
// transient reconnect of the SAME Socket.IO object restores active logical Listen subscriptions once the new handshake is ready.
|
|
126
|
+
// close()/dispose() and connect()/setToken() are terminal for the old generation. Ordinary RPC calls are NEVER retried.
|
|
127
|
+
|
|
128
|
+
// minimal wiring (the part no signature can show):
|
|
129
|
+
const [tick, ticks] = listen<[number]>()
|
|
130
|
+
createRpcServerAuto({ socket, object: { math: { add: (a, b) => a + b, ticks } }, socketKey: 'math' })
|
|
131
|
+
const hub = createRpcClientHub((t) => io(url, { auth: { t } }), (rpc) => ({ math: rpc<Api>('math') }))
|
|
132
|
+
const c = await hub.connect(token) // c = facade of per-socketKey clients
|
|
133
|
+
await c.math.ready(); await c.math.func.add(2, 3)
|
|
134
|
+
const l = c.math.func as unknown as DeepSocketListen<Api> // typed Listen projection; wrap as webListen(c.math) in app code
|
|
135
|
+
const off = l.ticks.on(v => console.log(v)) // canonical stream subscribe; off is callable and awaitable
|
|
136
|
+
off() // unsubscribe; .callback/.removeCallback are legacy compat, don't teach them
|
|
137
|
+
l.ticks.once(v => console.log(v)) // one event, then auto-off
|
|
138
|
+
|
|
139
|
+
// replay upgrade — ONE WORD at the declaration site, everything below follows automatically:
|
|
140
|
+
// const [tick, ticks] = listen<[number]>() // before
|
|
141
|
+
const [tick, ticks] = replayListen<[number]>({history: 1024, current: 'last'}) // after — same facade, same key
|
|
142
|
+
// legacy subscribers unchanged (byte-for-byte). Replay consumers now also get:
|
|
143
|
+
const sub = replaySubscribe(l.ticks, v => {}, {since: saved, onSeq: s => saved = s}) // catch-up + live; no uncovered loss/dups (a producer frame/keyframe may jump raw seq)
|
|
144
|
+
const sub2 = replaySubscribe(c.math.func.ticks, v => {}) // replay members project on func/strict directly — no cast needed
|
|
145
|
+
const routed = replayRouteSubscribe(l.ticks, v => {}, {label: 'relay'})
|
|
146
|
+
await routed.switch(nextRemoteTicks, {label: 'direct'}) // relay/direct hand-off: old route closes after catch-up
|
|
147
|
+
await l.ticks.frame(mySeq) // pull at YOUR pace (50ms timer etc.) — server condenses via the line's frame lambda
|
|
148
|
+
// full guide + examples → rpc.md; frame model / lag policies → 🎞️ recipe below and rare docs
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## 🎙️ Media over socket — binary Listen frames
|
|
152
|
+
```
|
|
153
|
+
import { Media } from "wenay-common2" // or: import * as Media from "wenay-common2/media"
|
|
154
|
+
|
|
155
|
+
Media.createAudioSource({format?: 'int16'|'float32', mode?: 'pcm'|'record', replay?}) -> [emit, listen] & control
|
|
156
|
+
Media.createVideoSource({fps? = 3, codec? = 'jpeg', quality?, replay?}) -> [emit, listen] & control
|
|
157
|
+
control: start() -> Promise<'idle'|'requesting'|'live'|'denied'|'no-device'|'error'> · stop() · getStats() · setDevice(id) · listDevices() · state
|
|
158
|
+
Media.encodeMediaFrame(meta, payload) / Media.decodeMediaFrame(frame) // one Uint8Array = 40-byte fixed header + raw payload
|
|
159
|
+
|
|
160
|
+
// viewer/publisher one-liners (the demo stand is built on these):
|
|
161
|
+
Media.attachVideoCanvas(line, canvas, {onError?}) -> {stats(), off} // decode+render any video line; codec/size come from frame headers
|
|
162
|
+
Media.attachAudioPlayer(line, {maxBacklogSec? = 0.35}) -> {enable(), disable(), enabled, stats(), off} // live PCM playback, backlog drops
|
|
163
|
+
Media.pipeMediaPublish(line, publish, {stamp? = true, onError?}) -> off // source -> RPC publish fn; stamp lets viewers measure latency
|
|
164
|
+
```
|
|
165
|
+
Audio default is PCM frames from `AudioWorklet` where available (`mode:'record'` uses MediaRecorder chunks). Video default is camera snapshots (JPEG, low fps for vision) captured hidden-tab-proof: a worker timer ticks (setInterval is throttled to ~1/s in hidden tabs), `ImageCapture.grabFrame()` reads the track (a hidden `<video>` stops painting), and JPEG encode runs in a worker (main-thread `convertToBlob` stalls ~1s hidden) — `worker:false` opts back into the plain in-page path. Screen share is the same video source with an injected stream: `createVideoSource({stream: () => navigator.mediaDevices.getDisplayMedia({video: true})})`. Put `listen` into `createRpcServerAuto` like any other Listen; with `replay:true`, the returned listen is a replay line, so RPC auto exposes legacy + replay surfaces under the same key. Backpressure policy: audio is lossless queue; video `replay:true` defaults to keep-latest frame recovery. `transport:'webrtc'` is reserved for a future SFU/signaling adapter; socket binary is the default today. Living example: the demo stand (`npm run demo`) streams camera / mic / screen share between two tabs through a tiny server-side relay of replay lines (`demo/server.ts` + `demo/client.ts`).
|
|
166
|
+
|
|
167
|
+
## 🤝 Peer — accounts see each other's stores (one-call SDK)
|
|
168
|
+
> `import { Peer } from "wenay-common2"` or `import * as Peer from "wenay-common2/peer"`.
|
|
169
|
+
> The happy-path facade over rpc + store + replay + route coordinator. Legacy-friendly by design:
|
|
170
|
+
> the server side is a FRAGMENT spread into your EXISTING `createRpcServerAuto` object, the client
|
|
171
|
+
> side rides your existing connection — old keys keep working untouched.
|
|
172
|
+
```
|
|
173
|
+
// SERVER — next to your legacy object:
|
|
174
|
+
const host = Peer.createPeerHost({authorize?, history?}) // authorize(env) = server-side canExposeEndpoint
|
|
175
|
+
io.on('connection', socket => {
|
|
176
|
+
const peer = host.connection(accountOf(socket)) // per-account signal port + relay journal
|
|
177
|
+
createRpcServerAuto({socket, socketKey, object: {...legacyObject, peer: peer.fragment}, disconnectListen})
|
|
178
|
+
disconnectListen.on(peer.close)
|
|
179
|
+
})
|
|
180
|
+
host: connection(account) · relay(account) · accounts() · revoke(pair, accounts, reason?) · close()
|
|
181
|
+
|
|
182
|
+
// CLIENT — the whole happy path:
|
|
183
|
+
const me = Peer.createPeerClient<World>({
|
|
184
|
+
remote: c.app.func.peer, // deep proxy of the fragment — rest of the connection is yours
|
|
185
|
+
account: 'a',
|
|
186
|
+
initial: {...}, // own store: write me.store.state — others see it
|
|
187
|
+
rtc?: () => new RTCPeerConnection(cfg), // omit = relay-only (promoteDirect unavailable)
|
|
188
|
+
})
|
|
189
|
+
const bob = me.peer('b') // mirror + route control for another account
|
|
190
|
+
await bob.ready // keyframe/tail landed
|
|
191
|
+
bob.store.state // live mirror — reads survive ANY route change
|
|
192
|
+
await bob.promoteDirect() // relay -> WebRTC direct; {ok, state, reason?} result, not a throw
|
|
193
|
+
bob.route() // 'relay' | 'direct' · bob.reinterposeRelay() · bob.fallback() · bob.block()
|
|
194
|
+
me.onRoute(ev => {}) // route transitions for metrics/UI
|
|
195
|
+
```
|
|
196
|
+
Key property: the relay journal stores the owner's envelopes VERBATIM (owner seq space), so a
|
|
197
|
+
relay <-> direct hand-off is a plain seq resume — no uncovered loss or duplicate delivery. Late joiners
|
|
193
198
|
get a keyframe folded server-side even while the owner is offline.
|
|
199
|
+
The same replay datachannel preserves `Media` `Uint8Array` frames byte-for-byte, so a direct route
|
|
200
|
+
can feed existing `Media` Listen/replay consumers; native WebRTC tracks/SFU are optional future
|
|
201
|
+
performance adapters, not a second media semantic.
|
|
194
202
|
Reconnect correctness is self-healing: a publisher gap makes the relay reject the push WITH its last
|
|
195
|
-
seq, and the client repairs from that coordinate automatically (`repair: 'tail'` lossless (default)
|
|
196
|
-
| `'keyframe'` cheap reset for ephemeral state). Server declares journal semantics
|
|
197
|
-
(`createPeerHost({gap: 'resume' | 'sacred'})`); a declared `journal: 'sacred'` TYPE-forbids the cheap
|
|
198
|
-
repair. `me.resync()` after a transport reconnect repairs without waiting for the next write;
|
|
199
|
-
failures surface via `onPublishError`, never silently. Policy/session material:
|
|
200
|
-
`createPeerClient({session, accept, policy})` + host `authorize` — see rare docs for the envelope
|
|
201
|
-
contract and the underlying primitives (`createRouteCoordinator`, `createSignalHub`,
|
|
202
|
-
`createWebRtcConnector`). Oracle: `replay/peer-sdk.test.ts`.
|
|
203
|
-
|
|
204
|
-
### Calls, presence and the media relay (messenger-style, on the same parts)
|
|
205
|
-
```
|
|
206
|
-
// presence rides in the fragment (host >= 1.0.74): subscribe FIRST, then list()
|
|
207
|
-
c.app.func.peer.presence.changes.on(({account, online}) => {})
|
|
208
|
-
await c.app.func.peer.presence.list() // ['a', 'b', ...] — connected right now
|
|
209
|
-
|
|
210
|
-
// SERVER — media relay next to the fragment (per-account named lines, policy-gated watch):
|
|
211
|
-
const media = Peer.createMediaRelay({
|
|
212
|
-
lines: {cam: 'video', mic: 'audio', screen: 'video'},
|
|
203
|
+
seq, and the client repairs from that coordinate automatically (`repair: 'tail'` lossless (default)
|
|
204
|
+
| `'keyframe'` cheap reset for ephemeral state). Server declares journal semantics
|
|
205
|
+
(`createPeerHost({gap: 'resume' | 'sacred'})`); a declared `journal: 'sacred'` TYPE-forbids the cheap
|
|
206
|
+
repair. `me.resync()` after a transport reconnect repairs without waiting for the next write;
|
|
207
|
+
failures surface via `onPublishError`, never silently. Policy/session material:
|
|
208
|
+
`createPeerClient({session, accept, policy})` + host `authorize` — see rare docs for the envelope
|
|
209
|
+
contract and the underlying primitives (`createRouteCoordinator`, `createSignalHub`,
|
|
210
|
+
`createWebRtcConnector`). Oracle: `replay/peer-sdk.test.ts`.
|
|
211
|
+
|
|
212
|
+
### Calls, presence and the media relay (messenger-style, on the same parts)
|
|
213
|
+
```
|
|
214
|
+
// presence rides in the fragment (host >= 1.0.74): subscribe FIRST, then list()
|
|
215
|
+
c.app.func.peer.presence.changes.on(({account, online}) => {})
|
|
216
|
+
await c.app.func.peer.presence.list() // ['a', 'b', ...] — connected right now
|
|
217
|
+
|
|
218
|
+
// SERVER — media relay next to the fragment (per-account named lines, policy-gated watch):
|
|
219
|
+
const media = Peer.createMediaRelay({
|
|
220
|
+
lines: {cam: 'video', mic: 'audio', screen: 'video'},
|
|
213
221
|
canWatch?: (watcher, owner, line) => bool, // ACL, checked on paths + every forwarded frame
|
|
214
|
-
})
|
|
215
|
-
object: {peer: peer.fragment, media: {publish: media.publishOf(account), watch: media.watchOf(account)}}
|
|
216
|
-
media.dropAccount(account) // retire lines + keyspace (e.g. on presence offline); next publish revives
|
|
217
|
-
|
|
218
|
-
// CLIENT — calls are envelopes over the SAME signal port; media attach stays app code:
|
|
219
|
-
const calls = Peer.createCallManager({port: Peer.callPortOf(c.app.func.peer), self: 'a'})
|
|
220
|
-
await calls.ready // signal subscription confirmed server-side
|
|
221
|
-
calls.rings.on(call => { call.accept() /* or call.decline() */ })
|
|
222
|
-
const call = calls.call('b', {kinds: ['cam', 'mic']}) // meta rides opaquely
|
|
223
|
-
call.changed.on(state => {}) // 'active' | 'ended'
|
|
224
|
-
await call.ended // 'declined'|'busy'|'offline'|'timeout'|'hangup'|'canceled'
|
|
225
|
-
call.hangup()
|
|
226
|
-
// while active: publish own frames + attach the peer's lines (Media.attach* viewers)
|
|
227
|
-
media.publish('cam', frame, Date.now()); attachVideoCanvas(c.app.func.media.watch.b.cam, canvas)
|
|
228
|
-
```
|
|
229
|
-
Relay-first is the privacy default (mainstream messengers route calls through their servers too);
|
|
230
|
-
`promoteDirect` stays the policy-gated opt-in for the data path. Ring/accept/decline/hangup ride the
|
|
231
|
-
existing signal hub — the host `authorize` hook sees them too (single server-side policy point), and
|
|
232
|
-
an offline callee fails fast (`'offline'`), no timeout wait. The default incoming gate auto-declines
|
|
233
|
-
with `'busy'` during a live call, which also settles simultaneous cross-calls. `video` relay lines
|
|
234
|
-
are keep-latest (late joiner pulls `keyframe()` instantly), `audio` is a short lossless queue; frames
|
|
235
|
-
ride as `[frame, sentAt]` so viewer latency stats work out of the box. Watch access is an ACL, not a
|
|
236
|
-
convention: `watchOf(account)` views run `canWatch` on every new subscribe/keyframe and every live
|
|
237
|
-
frame. "Media access follows the call" is a few lines of app code (grant on `'active'`, revoke on
|
|
238
|
-
`'ended'`); after revocation an already-open policy view receives no more data. Oracle:
|
|
239
|
-
`replay/peer-call.test.ts`.
|
|
240
|
-
|
|
241
|
-
## 🔁 Observe — reactive state + store/mirror API
|
|
242
|
-
> `import { Observe } from "wenay-common2"` or `import * as Observe from "wenay-common2/observe"`.
|
|
243
|
-
> This is the documented v2 reactive/store surface.
|
|
244
|
-
```
|
|
245
|
-
// coarse reactive object: subscribe to the fact that a subtree changed, then re-read current state
|
|
246
|
-
Observe.reactive<T extends object>(obj, opts?) -> T
|
|
247
|
-
Observe.onUpdate(node, cb: () => void) -> off
|
|
248
|
-
Observe.onUpdatePaths(node, cb: ({paths}) => void) -> off // optional dirty paths, relative to node
|
|
249
|
-
Observe.flushReactive(node) -> Promise<void>
|
|
250
|
-
Observe.toRaw(node) -> raw value behind the proxy // snapshots/serialization without touching lazy nodes
|
|
251
|
-
Observe.listenUpdate(node) -> Listen<void> // RPC bridge for coarse change notifications
|
|
252
|
-
Observe.listenUpdatePaths(node) -> Listen<{paths: PropertyKey[][]}>
|
|
253
|
-
opts: { drain?: "immediate"|"micro"|number|((flush)=>void), depth?, eager? }
|
|
254
|
-
|
|
255
|
-
// path-addressed store facade over reactive()
|
|
256
|
-
Observe.createStore<T extends object>(initial, opts?) -> Store<T>
|
|
257
|
-
store.state // reactive data object; write normally
|
|
258
|
-
store.node.path.to.leaf.get()/snapshot()/replace(v) // set(v) is a deprecated alias of replace(v)
|
|
259
|
-
store.node.path.to.leaf.on((value, ctx) => {}, {current?, drain?, key?}) -> off
|
|
260
|
-
store.node.path.to.leaf.once(cb, opts?) -> off
|
|
261
|
-
store.update(mask, opts?) -> selection // typed selected snapshot
|
|
262
|
-
selection.get() · selection.on((snap, ctx)=>{}, opts?) -> off · selection.onEach((value, ctx)=>{}, opts?) -> off
|
|
263
|
-
store.each(opts?) -> Listen<[key, value, ctx]> // changed TOP-LEVEL keys as a plain Listen — THE per-key feed
|
|
264
|
-
// one call per CHANGED key per drain window: value = current store.state[key] at flush time; undefined = key deleted;
|
|
265
|
-
// two writes to one key in a window = ONE call (last value); deeper dirt (state.a.b = ...) reports 'a' once
|
|
266
|
-
// root replace (store.replace / mirror keyframe) EXPANDS: one call per key of the new state + (key, undefined)
|
|
267
|
-
// per key the replace removed — cold start / reconnect are NOT special cases for per-key consumers
|
|
268
|
-
// plain Listen shape (on(cb) -> off · once · count); zero cost while it has no subscribers
|
|
269
|
-
// NOT update(true).onEach: onEach fires per SELECTED path, and mask true selects the root —
|
|
270
|
-
// ONE call per window with the whole dict (a dev warn points to each())
|
|
271
|
-
store.count() -> number
|
|
272
|
-
|
|
273
|
-
// network shape: backend exposes snapshots + changed Listen; frontend mirrors selected masks locally
|
|
274
|
-
Observe.exposeStore(store, opts?) -> { get(mask?), set(path,value), replace(path,value), changed, changedPaths, patches?, changedData? }
|
|
275
|
-
Observe.createStoreMirror(remote, initial, opts?) -> store & { sync(mask, opts?) -> Promise<off>; syncPatches(mask, opts?) -> Promise<off>; syncChangedData(mask, opts?) -> Promise<off> }
|
|
276
|
-
// changedPaths is optional optimization: mirror pulls mask ∩ dirty paths; fallback is changed -> get(mask).
|
|
277
|
-
// Optional push-data mode: exposeStore(store,{push:true}) + syncPatches/syncChangedData; details in rare docs.
|
|
278
|
-
|
|
279
|
-
// Sequenced sync (replay line): seq-numbered patch stream — keyframe catch-up, reconnect by seq (tail, not snapshot)
|
|
280
|
-
Observe.exposeStoreReplay(store, {history? = 1024}) -> { api /* spread into the RPC server object */, replay, close }
|
|
281
|
-
// the patch line declares its condensing `frame` itself (last patch per exact path) — reconnect tails and
|
|
282
|
-
// lag recovery arrive as a mini-frame (changed paths only), zero config
|
|
283
|
-
Observe.syncStoreReplay(mirror, remote /*{line, since, keyframe, frame?} of api.replay*/, {since?, onSeq?}) -> off
|
|
284
|
-
// off.ready (catch-up done) · off.seq() (save for reconnect: syncStoreReplay(..., {since: prev.seq()}))
|
|
285
|
-
// lagging/late client NEVER gets a backlog: evicted seq -> ONE fresh keyframe + live
|
|
286
|
-
// freshness is an option, not consumer boilerplate: {staleMs, onStale} flags a silent line / stale keyframe (edge-triggered both ways; 🎞️ in rare docs)
|
|
287
|
-
Observe.syncStoreReplayRoute(mirror, remote, {label?}) -> off & {switch(nextRemote, opts), ready, seq(), label(), active()}
|
|
288
|
-
// relay/direct promotion and re-interposition: replacement route catches up by seq before the old route closes
|
|
289
|
-
Observe.syncStoreReplayEach<T>(remote, (key, value, ctx) => {}, opts?) -> off & {store, ready, seq(), isStale(), lastTs()}
|
|
290
|
-
// one-call remote fold: mirror store + syncStoreReplay + store.each() — the callback fires per CHANGED
|
|
291
|
-
// top-level key; first delivery = keyframe EXPANDED per key; (key, undefined) = key deleted
|
|
292
|
-
// opts = all replaySubscribe opts (since/onSeq/policy/staleMs/onStale/onError...) + {drain?, initial?}
|
|
293
|
-
// off() tears down BOTH the store sub and the wire sub; direct reads via off.store.state.KEY
|
|
294
|
-
// reconnect: syncStoreReplayEach(remote, cb, {since: prev.seq(), initial: prev.store.snapshot()})
|
|
295
|
-
// — the tail lands ON TOP of the previous state (a fresh empty mirror would not converge)
|
|
296
|
-
// Offline persisted mirror (snapshot mode): local cache first, then replay catch-up by seq
|
|
297
|
-
Observe.createOfflineStore({key, remote?, initial, storage, version?, debounceMs?, syncOpts?}) -> Promise<store & {ready, flush(), close(), status(), statusListen, reconnect(remote)}>
|
|
298
|
-
Observe.persistStore(store, {key, storage, seq?, debounceMs?}) -> {flush, forceFlush, close, setSeq, seq, status, statusListen}
|
|
299
|
-
Observe.createMemoryOfflineStorage(initial?) -> OfflineStorage
|
|
300
|
-
// persists {version, seq, snapshot, savedAt}; seq is the correctness coordinate, timestamps are UX/freshness only
|
|
301
|
-
// mode:'topLevel' is reserved; first implemented mode is snapshot
|
|
302
|
-
|
|
303
|
-
// Declarative resource manager above mirror/replay/offline: app chooses what to start, not the store core
|
|
304
|
-
Observe.managedStore.mirror({remote, initial, mask, tags?, priority?, explicitOnly?, large?, sync?})
|
|
305
|
-
Observe.managedStore.replay({remote, initial, tags?, priority?, explicitOnly?, large?, syncOpts?})
|
|
306
|
-
Observe.managedStore.offline({remote?, initial, storage, storageKey?, tags?, priority?, explicitOnly?, large?, syncOpts?})
|
|
307
|
-
Observe.createStoreManager(resources) -> {plan(opts?), start(key, opts?), startPlanned(opts?), stop(key), stopAll(), get(key), touch(key, weight?), usage(), statusListen, handles}
|
|
308
|
-
// plan excludes explicitOnly/large by default; {includeExplicit, includeLarge} opts opt them in
|
|
309
|
-
// Slow-client conflation: recipe section 🎞️ below. Full generic surface (any event line, history/time-travel) -> Replay namespace, 🎞️ in rare docs.
|
|
310
|
-
// Object add/delete/deep set are paths. Array mutation dirties the whole array branch, not splice internals.
|
|
311
|
-
```
|
|
312
|
-
```
|
|
313
|
-
type Market = {data: {BTC?: number; ETH?: number}; meta: {status?: string}}
|
|
314
|
-
const market = Observe.createStore<Market>({data: {BTC: 1, ETH: 2}, meta: {status: "ok"}})
|
|
315
|
-
|
|
316
|
-
market.state.data.BTC = 3 // plain local mutation
|
|
317
|
-
market.node.data.BTC.on((v, ctx) => {}, {current: true}) // ctx.path = ["data", "BTC"]
|
|
318
|
-
market.node.data.on(data => {}, {current: true, drain: 50}) // branch snapshot, per-sub drain
|
|
319
|
-
market.update({data: {BTC: true, ETH: true}}, {current: true}).on(snap => {})
|
|
320
|
-
|
|
321
|
-
// backend facade over RPC
|
|
322
|
-
const api = Observe.exposeStore(market)
|
|
323
|
-
|
|
324
|
-
// frontend mirror: UI subscribes to local mirror, not RPC directly
|
|
325
|
-
const mirror = Observe.createStoreMirror<Market>(api, {data: {}, meta: {}})
|
|
326
|
-
const stop = await mirror.sync({data: {BTC: true}, meta: {status: true}}, {current: true, drain: 250}) // uses changedPaths when available
|
|
327
|
-
mirror.node.data.BTC.on(v => {}, {current: true})
|
|
328
|
-
stop()
|
|
329
|
-
|
|
330
|
-
// per-key feed — dict store -> grid rows; keyframe / reconnect are just expansion, not special cases
|
|
331
|
-
type Rows = Record<string, {qty: number}>
|
|
332
|
-
const rows = Observe.createStore<Rows>({})
|
|
333
|
-
const offRows = rows.each().on((key, row) => { /* row === undefined ? removeRow(key) : upsertRow(key, row) */ })
|
|
334
|
-
|
|
335
|
-
// the same per-key contract over the wire — ONE call (mirror store + syncStoreReplay + each)
|
|
336
|
-
const exposed = Observe.exposeStoreReplay(rows, {history: 1024}) // server side: spread exposed.api into the RPC object
|
|
337
|
-
const feed = Observe.syncStoreReplayEach<Rows>(exposed.api.replay, (key, row) => {}, {drain: "micro"})
|
|
338
|
-
await feed.ready // catch-up done: keyframe arrived expanded per key
|
|
339
|
-
feed.store.state // the mirror — direct reads / extra subscriptions
|
|
340
|
-
feed() // tears down the store sub AND the wire sub
|
|
341
|
-
// reconnect later: syncStoreReplayEach(remote, cb, {since: feed.seq(), initial: feed.store.snapshot()})
|
|
342
|
-
// offline persisted mirror — cached snapshot first, then replay catch-up over the same remote
|
|
343
|
-
const offline = await Observe.createOfflineStore<Rows>({
|
|
344
|
-
key: "rows",
|
|
345
|
-
remote: exposed.api.replay,
|
|
346
|
-
initial: {},
|
|
347
|
-
storage: Observe.createMemoryOfflineStorage(), // use IndexedDB/SQLite adapter in an app
|
|
348
|
-
debounceMs: 250,
|
|
349
|
-
})
|
|
350
|
-
offline.each().on((key, row) => {})
|
|
351
|
-
await offline.ready
|
|
352
|
-
await offline.flush()
|
|
353
|
-
offline.close()
|
|
354
|
-
|
|
355
|
-
// configurable app-level resource plan
|
|
356
|
-
const manager = Observe.createStoreManager({
|
|
357
|
-
market: Observe.managedStore.mirror({remote: api.market, initial: {data: {}, meta: {}}, mask: {data: {BTC: true}}, tags: ['bootstrap'], priority: 10}),
|
|
358
|
-
rows: Observe.managedStore.offline({remote: exposed.api.replay, initial: {}, storage: Observe.createMemoryOfflineStorage(), tags: ['grid']}),
|
|
359
|
-
video: Observe.managedStore.replay({remote: videoReplay, initial: {}, explicitOnly: true, large: true}),
|
|
360
|
-
})
|
|
361
|
-
await manager.startPlanned({tags: ['bootstrap']})
|
|
362
|
-
manager.touch('rows', 3) // usage can raise future plan score
|
|
363
|
-
await manager.start('video', {explicit: true})
|
|
364
|
-
```
|
|
365
|
-
Runnable example: `npx tsx observe/store-mirror.example.ts`.
|
|
366
|
-
Offline oracles: `npx tsx replay/offline-store.test.ts`; real Socket.IO/RPC wire: `npx tsx replay/offline-store-socket.test.ts`.
|
|
367
|
-
|
|
368
|
-
## 🎞️ Fast ticks vs slow client — replay lines + server-owned lag gate (recipe)
|
|
369
|
-
> The problem: the producer emits faster than a bad link drains. Naive streaming grows an unbounded
|
|
370
|
-
> outgoing queue per slow client. The replay stack solves it with ONE mental model — the FRAME:
|
|
371
|
-
> `frame(sinceSeq, hint?)` on the line returns envelopes bringing a consumer from `sinceSeq` to now,
|
|
372
|
-
> as compact as the line allows (exact tail -> condensed mini-frame -> keyframe fallback). The same
|
|
373
|
-
> method serves reconnect (`since`), client pull (own pace) and lag recovery. The transport sees only
|
|
374
|
-
> `seq`; ALL event semantics live in two lambdas declared on the line: `current` (keyframe = pointer
|
|
375
|
-
> to truth) and `frame` (condenser — may honor a client-supplied `hint`, see below).
|
|
376
|
-
```ts
|
|
377
|
-
import { Observe, Replay } from 'wenay-common2'
|
|
378
|
-
|
|
379
|
-
// ---- producer: declare what the line HAS (its class follows — no mode flags) ----
|
|
380
|
-
const [emitQuote, quotes] = Replay.replayListen<[string, number]>({
|
|
381
|
-
history: 4096,
|
|
382
|
-
frame: (tail, hint) => lastPerSymbol(tail, hint), // mini-frame; hint = client's pick of the condensation rule
|
|
383
|
-
}) // current+frame =
|
|
384
|
-
// store lines: exposeStoreReplay already declares current + frame (last patch per path) — zero config
|
|
385
|
-
const store = Observe.createStore<World>(initial, { drain: 'micro' })
|
|
386
|
-
const exposed = Observe.exposeStoreReplay(store, { history: 1024 })
|
|
387
|
-
|
|
388
|
-
// ---- per CONNECTION: the rpc server owns the gate; the facade does NOT change ----
|
|
389
|
-
io.on('connection', socket => {
|
|
390
|
-
const [disconnect, disconnectListen] = listen<[]>()
|
|
391
|
-
socket.on('disconnect', () => disconnect())
|
|
392
|
-
createRpcServerAuto({
|
|
393
|
-
socket: { emit: (k, d) => socket.emit(k, d), on: (k, cb) => socket.on(k, cb) },
|
|
394
|
-
socketKey: 'world',
|
|
395
|
-
object: { ...exposed.api, quotes }, // replay lines auto-exposed: both surfaces, same key
|
|
396
|
-
disconnectListen, // gates close on disconnect automatically
|
|
397
|
-
replayOpts: { highWater: 64, lowWater: 8 },// arms frameLine; pending defaults to socket.io writeBuffer
|
|
398
|
-
})
|
|
399
|
-
})
|
|
400
|
-
|
|
401
|
-
// ---- client: picks its LAG POLICY per subscription; no conflation logic anywhere ----
|
|
402
|
-
const sub = Replay.replaySubscribe(deep.quotes, cb, {since: saved, policy: 'frame'}) // server may skip; drain -> mini-frame
|
|
403
|
-
const sub2 = Replay.replaySubscribe(deep.quotes, cb2, {since: saved}) // 'queue' (default):
|
|
404
|
-
// own pace (e.g. 50ms skips + condensation): pull on YOUR timer — hint picks the rule, server condenses:
|
|
405
|
-
// every(50, async () => { for (const ev of await deep.quotes.frame(mySeq, hint)) apply(ev); })
|
|
406
|
-
// store mirror: Observe.syncStoreReplay(mirror, deep.replay, {since: prev.seq()}) — same contract
|
|
407
|
-
// delivery contract: FIRST delivery = snapshot/tail start (same event type), then strictly-newer,
|
|
408
|
-
// seq-ascending
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
`
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
222
|
+
})
|
|
223
|
+
object: {peer: peer.fragment, media: {publish: media.publishOf(account), watch: media.watchOf(account)}}
|
|
224
|
+
media.dropAccount(account) // retire lines + keyspace (e.g. on presence offline); next publish revives
|
|
225
|
+
|
|
226
|
+
// CLIENT — calls are envelopes over the SAME signal port; media attach stays app code:
|
|
227
|
+
const calls = Peer.createCallManager({port: Peer.callPortOf(c.app.func.peer), self: 'a'})
|
|
228
|
+
await calls.ready // signal subscription confirmed server-side
|
|
229
|
+
calls.rings.on(call => { call.accept() /* or call.decline() */ })
|
|
230
|
+
const call = calls.call('b', {kinds: ['cam', 'mic']}) // meta rides opaquely
|
|
231
|
+
call.changed.on(state => {}) // 'active' | 'ended'
|
|
232
|
+
await call.ended // 'declined'|'busy'|'offline'|'timeout'|'hangup'|'canceled'
|
|
233
|
+
call.hangup()
|
|
234
|
+
// while active: publish own frames + attach the peer's lines (Media.attach* viewers)
|
|
235
|
+
media.publish('cam', frame, Date.now()); attachVideoCanvas(c.app.func.media.watch.b.cam, canvas)
|
|
236
|
+
```
|
|
237
|
+
Relay-first is the privacy default (mainstream messengers route calls through their servers too);
|
|
238
|
+
`promoteDirect` stays the policy-gated opt-in for the data path. Ring/accept/decline/hangup ride the
|
|
239
|
+
existing signal hub — the host `authorize` hook sees them too (single server-side policy point), and
|
|
240
|
+
an offline callee fails fast (`'offline'`), no timeout wait. The default incoming gate auto-declines
|
|
241
|
+
with `'busy'` during a live call, which also settles simultaneous cross-calls. `video` relay lines
|
|
242
|
+
are keep-latest (late joiner pulls `keyframe()` instantly), `audio` is a short lossless queue; frames
|
|
243
|
+
ride as `[frame, sentAt]` so viewer latency stats work out of the box. Watch access is an ACL, not a
|
|
244
|
+
convention: `watchOf(account)` views run `canWatch` on every new subscribe/keyframe and every live
|
|
245
|
+
frame. "Media access follows the call" is a few lines of app code (grant on `'active'`, revoke on
|
|
246
|
+
`'ended'`); after revocation an already-open policy view receives no more data. Oracle:
|
|
247
|
+
`replay/peer-call.test.ts`.
|
|
248
|
+
|
|
249
|
+
## 🔁 Observe — reactive state + store/mirror API
|
|
250
|
+
> `import { Observe } from "wenay-common2"` or `import * as Observe from "wenay-common2/observe"`.
|
|
251
|
+
> This is the documented v2 reactive/store surface.
|
|
252
|
+
```
|
|
253
|
+
// coarse reactive object: subscribe to the fact that a subtree changed, then re-read current state
|
|
254
|
+
Observe.reactive<T extends object>(obj, opts?) -> T
|
|
255
|
+
Observe.onUpdate(node, cb: () => void) -> off
|
|
256
|
+
Observe.onUpdatePaths(node, cb: ({paths}) => void) -> off // optional dirty paths, relative to node
|
|
257
|
+
Observe.flushReactive(node) -> Promise<void>
|
|
258
|
+
Observe.toRaw(node) -> raw value behind the proxy // snapshots/serialization without touching lazy nodes
|
|
259
|
+
Observe.listenUpdate(node) -> Listen<void> // RPC bridge for coarse change notifications
|
|
260
|
+
Observe.listenUpdatePaths(node) -> Listen<{paths: PropertyKey[][]}>
|
|
261
|
+
opts: { drain?: "immediate"|"micro"|number|((flush)=>void), depth?, eager? }
|
|
262
|
+
|
|
263
|
+
// path-addressed store facade over reactive()
|
|
264
|
+
Observe.createStore<T extends object>(initial, opts?) -> Store<T>
|
|
265
|
+
store.state // reactive data object; write normally
|
|
266
|
+
store.node.path.to.leaf.get()/snapshot()/replace(v) // set(v) is a deprecated alias of replace(v)
|
|
267
|
+
store.node.path.to.leaf.on((value, ctx) => {}, {current?, drain?, key?}) -> off
|
|
268
|
+
store.node.path.to.leaf.once(cb, opts?) -> off
|
|
269
|
+
store.update(mask, opts?) -> selection // typed selected snapshot
|
|
270
|
+
selection.get() · selection.on((snap, ctx)=>{}, opts?) -> off · selection.onEach((value, ctx)=>{}, opts?) -> off
|
|
271
|
+
store.each(opts?) -> Listen<[key, value, ctx]> // changed TOP-LEVEL keys as a plain Listen — THE per-key feed
|
|
272
|
+
// one call per CHANGED key per drain window: value = current store.state[key] at flush time; undefined = key deleted;
|
|
273
|
+
// two writes to one key in a window = ONE call (last value); deeper dirt (state.a.b = ...) reports 'a' once
|
|
274
|
+
// root replace (store.replace / mirror keyframe) EXPANDS: one call per key of the new state + (key, undefined)
|
|
275
|
+
// per key the replace removed — cold start / reconnect are NOT special cases for per-key consumers
|
|
276
|
+
// plain Listen shape (on(cb) -> off · once · count); zero cost while it has no subscribers
|
|
277
|
+
// NOT update(true).onEach: onEach fires per SELECTED path, and mask true selects the root —
|
|
278
|
+
// ONE call per window with the whole dict (a dev warn points to each())
|
|
279
|
+
store.count() -> number
|
|
280
|
+
|
|
281
|
+
// network shape: backend exposes snapshots + changed Listen; frontend mirrors selected masks locally
|
|
282
|
+
Observe.exposeStore(store, opts?) -> { get(mask?), set(path,value), replace(path,value), changed, changedPaths, patches?, changedData? }
|
|
283
|
+
Observe.createStoreMirror(remote, initial, opts?) -> store & { sync(mask, opts?) -> Promise<off>; syncPatches(mask, opts?) -> Promise<off>; syncChangedData(mask, opts?) -> Promise<off> }
|
|
284
|
+
// changedPaths is optional optimization: mirror pulls mask ∩ dirty paths; fallback is changed -> get(mask).
|
|
285
|
+
// Optional push-data mode: exposeStore(store,{push:true}) + syncPatches/syncChangedData; details in rare docs.
|
|
286
|
+
|
|
287
|
+
// Sequenced sync (replay line): seq-numbered patch stream — keyframe catch-up, reconnect by seq (tail, not snapshot)
|
|
288
|
+
Observe.exposeStoreReplay(store, {history? = 1024}) -> { api /* spread into the RPC server object */, replay, close }
|
|
289
|
+
// the patch line declares its condensing `frame` itself (last patch per exact path) — reconnect tails and
|
|
290
|
+
// lag recovery arrive as a mini-frame (changed paths only), zero config
|
|
291
|
+
Observe.syncStoreReplay(mirror, remote /*{line, since, keyframe, frame?} of api.replay*/, {since?, onSeq?}) -> off
|
|
292
|
+
// off.ready (catch-up done) · off.seq() (save for reconnect: syncStoreReplay(..., {since: prev.seq()}))
|
|
293
|
+
// lagging/late client NEVER gets a backlog: evicted seq -> ONE fresh keyframe + live
|
|
294
|
+
// freshness is an option, not consumer boilerplate: {staleMs, onStale} flags a silent line / stale keyframe (edge-triggered both ways; 🎞️ in rare docs)
|
|
295
|
+
Observe.syncStoreReplayRoute(mirror, remote, {label?}) -> off & {switch(nextRemote, opts), ready, seq(), label(), active()}
|
|
296
|
+
// relay/direct promotion and re-interposition: replacement route catches up by seq before the old route closes
|
|
297
|
+
Observe.syncStoreReplayEach<T>(remote, (key, value, ctx) => {}, opts?) -> off & {store, ready, seq(), isStale(), lastTs()}
|
|
298
|
+
// one-call remote fold: mirror store + syncStoreReplay + store.each() — the callback fires per CHANGED
|
|
299
|
+
// top-level key; first delivery = keyframe EXPANDED per key; (key, undefined) = key deleted
|
|
300
|
+
// opts = all replaySubscribe opts (since/onSeq/policy/staleMs/onStale/onError...) + {drain?, initial?}
|
|
301
|
+
// off() tears down BOTH the store sub and the wire sub; direct reads via off.store.state.KEY
|
|
302
|
+
// reconnect: syncStoreReplayEach(remote, cb, {since: prev.seq(), initial: prev.store.snapshot()})
|
|
303
|
+
// — the tail lands ON TOP of the previous state (a fresh empty mirror would not converge)
|
|
304
|
+
// Offline persisted mirror (snapshot mode): local cache first, then replay catch-up by seq
|
|
305
|
+
Observe.createOfflineStore({key, remote?, initial, storage, version?, debounceMs?, syncOpts?}) -> Promise<store & {ready, flush(), close(), status(), statusListen, reconnect(remote)}>
|
|
306
|
+
Observe.persistStore(store, {key, storage, seq?, debounceMs?}) -> {flush, forceFlush, close, setSeq, seq, status, statusListen}
|
|
307
|
+
Observe.createMemoryOfflineStorage(initial?) -> OfflineStorage
|
|
308
|
+
// persists {version, seq, snapshot, savedAt}; seq is the correctness coordinate, timestamps are UX/freshness only
|
|
309
|
+
// mode:'topLevel' is reserved; first implemented mode is snapshot
|
|
310
|
+
|
|
311
|
+
// Declarative resource manager above mirror/replay/offline: app chooses what to start, not the store core
|
|
312
|
+
Observe.managedStore.mirror({remote, initial, mask, tags?, priority?, explicitOnly?, large?, sync?})
|
|
313
|
+
Observe.managedStore.replay({remote, initial, tags?, priority?, explicitOnly?, large?, syncOpts?})
|
|
314
|
+
Observe.managedStore.offline({remote?, initial, storage, storageKey?, tags?, priority?, explicitOnly?, large?, syncOpts?})
|
|
315
|
+
Observe.createStoreManager(resources) -> {plan(opts?), start(key, opts?), startPlanned(opts?), stop(key), stopAll(), get(key), touch(key, weight?), usage(), statusListen, handles}
|
|
316
|
+
// plan excludes explicitOnly/large by default; {includeExplicit, includeLarge} opts opt them in
|
|
317
|
+
// Slow-client conflation: recipe section 🎞️ below. Full generic surface (any event line, history/time-travel) -> Replay namespace, 🎞️ in rare docs.
|
|
318
|
+
// Object add/delete/deep set are paths. Array mutation dirties the whole array branch, not splice internals.
|
|
319
|
+
```
|
|
320
|
+
```
|
|
321
|
+
type Market = {data: {BTC?: number; ETH?: number}; meta: {status?: string}}
|
|
322
|
+
const market = Observe.createStore<Market>({data: {BTC: 1, ETH: 2}, meta: {status: "ok"}})
|
|
323
|
+
|
|
324
|
+
market.state.data.BTC = 3 // plain local mutation
|
|
325
|
+
market.node.data.BTC.on((v, ctx) => {}, {current: true}) // ctx.path = ["data", "BTC"]
|
|
326
|
+
market.node.data.on(data => {}, {current: true, drain: 50}) // branch snapshot, per-sub drain
|
|
327
|
+
market.update({data: {BTC: true, ETH: true}}, {current: true}).on(snap => {})
|
|
328
|
+
|
|
329
|
+
// backend facade over RPC
|
|
330
|
+
const api = Observe.exposeStore(market)
|
|
331
|
+
|
|
332
|
+
// frontend mirror: UI subscribes to local mirror, not RPC directly
|
|
333
|
+
const mirror = Observe.createStoreMirror<Market>(api, {data: {}, meta: {}})
|
|
334
|
+
const stop = await mirror.sync({data: {BTC: true}, meta: {status: true}}, {current: true, drain: 250}) // uses changedPaths when available
|
|
335
|
+
mirror.node.data.BTC.on(v => {}, {current: true})
|
|
336
|
+
stop()
|
|
337
|
+
|
|
338
|
+
// per-key feed — dict store -> grid rows; keyframe / reconnect are just expansion, not special cases
|
|
339
|
+
type Rows = Record<string, {qty: number}>
|
|
340
|
+
const rows = Observe.createStore<Rows>({})
|
|
341
|
+
const offRows = rows.each().on((key, row) => { /* row === undefined ? removeRow(key) : upsertRow(key, row) */ })
|
|
342
|
+
|
|
343
|
+
// the same per-key contract over the wire — ONE call (mirror store + syncStoreReplay + each)
|
|
344
|
+
const exposed = Observe.exposeStoreReplay(rows, {history: 1024}) // server side: spread exposed.api into the RPC object
|
|
345
|
+
const feed = Observe.syncStoreReplayEach<Rows>(exposed.api.replay, (key, row) => {}, {drain: "micro"})
|
|
346
|
+
await feed.ready // catch-up done: keyframe arrived expanded per key
|
|
347
|
+
feed.store.state // the mirror — direct reads / extra subscriptions
|
|
348
|
+
feed() // tears down the store sub AND the wire sub
|
|
349
|
+
// reconnect later: syncStoreReplayEach(remote, cb, {since: feed.seq(), initial: feed.store.snapshot()})
|
|
350
|
+
// offline persisted mirror — cached snapshot first, then replay catch-up over the same remote
|
|
351
|
+
const offline = await Observe.createOfflineStore<Rows>({
|
|
352
|
+
key: "rows",
|
|
353
|
+
remote: exposed.api.replay,
|
|
354
|
+
initial: {},
|
|
355
|
+
storage: Observe.createMemoryOfflineStorage(), // use IndexedDB/SQLite adapter in an app
|
|
356
|
+
debounceMs: 250,
|
|
357
|
+
})
|
|
358
|
+
offline.each().on((key, row) => {})
|
|
359
|
+
await offline.ready
|
|
360
|
+
await offline.flush()
|
|
361
|
+
offline.close()
|
|
362
|
+
|
|
363
|
+
// configurable app-level resource plan
|
|
364
|
+
const manager = Observe.createStoreManager({
|
|
365
|
+
market: Observe.managedStore.mirror({remote: api.market, initial: {data: {}, meta: {}}, mask: {data: {BTC: true}}, tags: ['bootstrap'], priority: 10}),
|
|
366
|
+
rows: Observe.managedStore.offline({remote: exposed.api.replay, initial: {}, storage: Observe.createMemoryOfflineStorage(), tags: ['grid']}),
|
|
367
|
+
video: Observe.managedStore.replay({remote: videoReplay, initial: {}, explicitOnly: true, large: true}),
|
|
368
|
+
})
|
|
369
|
+
await manager.startPlanned({tags: ['bootstrap']})
|
|
370
|
+
manager.touch('rows', 3) // usage can raise future plan score
|
|
371
|
+
await manager.start('video', {explicit: true})
|
|
372
|
+
```
|
|
373
|
+
Runnable example: `npx tsx observe/store-mirror.example.ts`.
|
|
374
|
+
Offline oracles: `npx tsx replay/offline-store.test.ts`; real Socket.IO/RPC wire: `npx tsx replay/offline-store-socket.test.ts`.
|
|
375
|
+
|
|
376
|
+
## 🎞️ Fast ticks vs slow client — replay lines + server-owned lag gate (recipe)
|
|
377
|
+
> The problem: the producer emits faster than a bad link drains. Naive streaming grows an unbounded
|
|
378
|
+
> outgoing queue per slow client. The replay stack solves it with ONE mental model — the FRAME:
|
|
379
|
+
> `frame(sinceSeq, hint?)` on the line returns envelopes bringing a consumer from `sinceSeq` to now,
|
|
380
|
+
> as compact as the line allows (exact tail -> condensed mini-frame -> keyframe fallback). The same
|
|
381
|
+
> method serves reconnect (`since`), client pull (own pace) and lag recovery. The transport sees only
|
|
382
|
+
> `seq`; ALL event semantics live in two lambdas declared on the line: `current` (keyframe = pointer
|
|
383
|
+
> to truth) and `frame` (condenser — may honor a client-supplied `hint`, see below).
|
|
384
|
+
```ts
|
|
385
|
+
import { Observe, Replay } from 'wenay-common2'
|
|
386
|
+
|
|
387
|
+
// ---- producer: declare what the line HAS (its class follows — no mode flags) ----
|
|
388
|
+
const [emitQuote, quotes] = Replay.replayListen<[string, number]>({
|
|
389
|
+
history: 4096,
|
|
390
|
+
frame: (tail, hint) => lastPerSymbol(tail, hint), // mini-frame; hint = client's pick of the condensation rule
|
|
391
|
+
}) // current+frame = compact + keyframe fallback · current only = exact tail + keyframe · frame only = compact while retained, loud on eviction · neither = sacred exact queue
|
|
392
|
+
// store lines: exposeStoreReplay already declares current + frame (last patch per path) — zero config
|
|
393
|
+
const store = Observe.createStore<World>(initial, { drain: 'micro' })
|
|
394
|
+
const exposed = Observe.exposeStoreReplay(store, { history: 1024 })
|
|
395
|
+
|
|
396
|
+
// ---- per CONNECTION: the rpc server owns the gate; the facade does NOT change ----
|
|
397
|
+
io.on('connection', socket => {
|
|
398
|
+
const [disconnect, disconnectListen] = listen<[]>()
|
|
399
|
+
socket.on('disconnect', () => disconnect())
|
|
400
|
+
createRpcServerAuto({
|
|
401
|
+
socket: { emit: (k, d) => socket.emit(k, d), on: (k, cb) => socket.on(k, cb) },
|
|
402
|
+
socketKey: 'world',
|
|
403
|
+
object: { ...exposed.api, quotes }, // replay lines auto-exposed: both surfaces, same key
|
|
404
|
+
disconnectListen, // gates close on disconnect automatically
|
|
405
|
+
replayOpts: { highWater: 64, lowWater: 8 },// arms frameLine; pending defaults to socket.io writeBuffer
|
|
406
|
+
})
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
// ---- client: picks its LAG POLICY per subscription; no conflation logic anywhere ----
|
|
410
|
+
const sub = Replay.replaySubscribe(deep.quotes, cb, {since: saved, policy: 'frame'}) // server may skip live envelopes; drain -> state-equivalent mini-frame
|
|
411
|
+
const sub2 = Replay.replaySubscribe(deep.quotes, cb2, {since: saved}) // 'queue' (default): ungated live; catch-up may still use producer frame/keyframe
|
|
412
|
+
// own pace (e.g. 50ms skips + condensation): pull on YOUR timer — hint picks the rule, server condenses:
|
|
413
|
+
// every(50, async () => { for (const ev of await deep.quotes.frame(mySeq, hint)) apply(ev); })
|
|
414
|
+
// store mirror: Observe.syncStoreReplay(mirror, deep.replay, {since: prev.seq()}) — same contract
|
|
415
|
+
// delivery contract: FIRST delivery = snapshot/tail start (same event type), then strictly-newer,
|
|
416
|
+
// seq-ascending and deduped. Raw seq jumps are legal only when a producer frame/keyframe covers them;
|
|
417
|
+
// a sacred retained tail is exact, while sacred eviction closes with onError instead of continuing past a hole.
|
|
418
|
+
```
|
|
419
|
+
Rules that make it correct (violating any of these silently breaks convergence):
|
|
420
|
+
- **The line declares its recovery sources.** `current` (keyframe: SAMPLED from truth, never computed
|
|
421
|
+
from deltas; `'last'` = last envelope for single-entity lines) and/or `frame` (condenser). Without
|
|
422
|
+
`current`, eviction is terminal even when a condenser exists: a true sacred queue (neither source)
|
|
423
|
+
replays the retained tail exactly, and `frame()` THROWS once that tail is gone. A lagging
|
|
424
|
+
`'frame'`-policy sacred subscriber gets a stream end, never silent loss.
|
|
425
|
+
- **A `frame` result must be state-equivalent to the tail it replaces** (per the line's own semantics).
|
|
426
|
+
"Can't condense THIS tail" is legal and simple: return the tail as-is. Refuse-loudly is `throw`.
|
|
427
|
+
Multiple condensation standards live INSIDE the lambda, dispatched by the client-supplied `hint`
|
|
428
|
+
(opaque to the transport): `frame(tail, hint)`.
|
|
429
|
+
- **Events must be ABSOLUTE per their entity** for last-per-entity condensing (store patches are).
|
|
430
|
+
- Gate drops never hole the journal — it is written BEFORE any gate. Queue-policy live delivery is
|
|
431
|
+
ungated; reconnect catch-up may use the producer's state-equivalent mini-frame. An evicted state
|
|
432
|
+
line falls back to a keyframe (visible raw seq jump); an evicted sacred line fails terminally.
|
|
433
|
+
- `pending()` and the watermarks share units (bytes, packets, frames — anything, but the same).
|
|
434
|
+
|
|
435
|
+
Manual path (pre-rev2, still works, `keyOf` @deprecated): build the gate yourself with
|
|
436
|
+
`Replay.conflateReplay(exposed.replay, {pending, highWater, keyOf})` and spread `gated.api` into the
|
|
437
|
+
facade — details in rare docs. New code should declare `frame` on the line instead.
|
|
438
|
+
|
|
439
|
+
Wire-level proof/oracles: `npx ts-node replay/rpc-auto.test.ts` (real Socket.IO: auto-exposure, legacy
|
|
440
|
+
parity, frame equivalence, gate lag sim), plus `replay/conflate-socket.test.ts`, `replay/conflate.test.ts`,
|
|
441
|
+
`replay/coalesce.test.ts`. Full generic surface (history/time-travel, archive) → 🎞️ in rare docs.
|