wenay-common2 1.0.73 → 1.0.74
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 +3 -2
- package/demo/client.ts +137 -32
- package/demo/index.html +7 -0
- package/demo/server.ts +79 -42
- package/doc/INTENT.md +31 -31
- package/doc/ROADMAP.md +233 -230
- package/doc/changes/1.0.74.md +12 -0
- package/doc/wenay-common2-rare.md +684 -648
- package/doc/wenay-common2.md +37 -3
- package/lib/Common/events/route-signal-webrtc.d.ts +1 -1
- package/lib/Common/events/route-signal-webrtc.js +15 -5
- package/lib/Common/media/media-view.d.ts +2 -2
- package/lib/Common/media/media-view.js +23 -4
- package/lib/Common/peer/peer-call.d.ts +65 -0
- package/lib/Common/peer/peer-call.js +173 -0
- package/lib/Common/peer/peer-client.d.ts +9 -0
- package/lib/Common/peer/peer-host.d.ts +14 -1
- package/lib/Common/peer/peer-host.js +48 -4
- package/lib/Common/peer/peer-index.d.ts +2 -0
- package/lib/Common/peer/peer-index.js +2 -0
- package/lib/Common/peer/peer-media-relay.d.ts +22 -0
- package/lib/Common/peer/peer-media-relay.js +155 -0
- package/observe/listen-core.test.ts +1 -2
- package/package.json +1 -4
- package/replay/media-view.test.ts +4 -4
- package/replay/peer-call.test.ts +226 -0
- package/doc/changes/1.0.64.md +0 -10
|
@@ -1,649 +1,685 @@
|
|
|
1
|
-
# wenay-common2 — EXTENDED cheat sheet (notation)
|
|
2
|
-
|
|
3
|
-
> The full surface. For everyday helpers use **`wenay-common2.md`** (brief). Root import:
|
|
4
|
-
> `import { ... } from "wenay-common2"`. Notation: `name(args) -> ret // note`. Short names are
|
|
5
|
-
> canonical. Removed old names are listed in `NAMING_RENAMES.md`.
|
|
6
|
-
|
|
7
|
-
## 🔔 events (rare)
|
|
1
|
+
# wenay-common2 — EXTENDED cheat sheet (notation)
|
|
2
|
+
|
|
3
|
+
> The full surface. For everyday helpers use **`wenay-common2.md`** (brief). Root import:
|
|
4
|
+
> `import { ... } from "wenay-common2"`. Notation: `name(args) -> ret // note`. Short names are
|
|
5
|
+
> canonical. Removed old names are listed in `NAMING_RENAMES.md`.
|
|
6
|
+
|
|
7
|
+
## 🔔 events (rare)
|
|
8
|
+
```
|
|
9
|
+
new CObjectEventsArr<T>() / new CObjectEventsList<T>(log?=true) // handler collections
|
|
10
|
+
.add(item, {at?:'start'|'end'}) // item: { func?, func2?, del?, OnDel? }; default end (func2 = run-once-then-del)
|
|
11
|
+
.emit(data?) · .clear() · get size · .OnSpecEvent(fn)
|
|
12
|
+
// alias: Add/AddEnd->add · AddStart->add(_, {at:'start'}) · OnEvent->emit · Clean->clear · count/length->size
|
|
13
|
+
// CObjectEventsList warns at >20 subscriptions (leak detector)
|
|
14
|
+
|
|
15
|
+
Listen API from root import:
|
|
16
|
+
createListenCore<T>(opts?) -> core // minimal hot path: emit/on/off/once/close/count/keys
|
|
17
|
+
createListen<T>(producer, opts?) -> full // producer receives emit and may return teardown
|
|
18
|
+
createFastListen<T>(producer) -> full // createListen(..., {fast:true})
|
|
19
|
+
listen<T>(opts?) -> [emit, full] // pure event list: no value storage/current replay
|
|
20
|
+
slimListen<T>(opts?) -> [emit, slim] // slim view: on/off/close/count only
|
|
21
|
+
withStoreListen(full, currentGetter) -> storeListen
|
|
22
|
+
createStoreListen<T>(producer, {current,...opts}) -> storeListen
|
|
23
|
+
listenStore<T>({current,...opts}) -> [emit, storeListen]
|
|
24
|
+
// storeListen stores only the current getter reference, not store values
|
|
25
|
+
|
|
26
|
+
full Listen: .emit(...args) · .on(cb,{key?,cbClose?})->off · .off(key|cb) · .once(cb,{key?})->off · .onClose(cb)->off · .run() · .isRunning() · .close() · .count() · .keys()
|
|
27
|
+
store Listen: .on(cb,{current?:true|()=>args,key?,cbClose?})->off · .once(cb,{current?:true|()=>args,key?})->off
|
|
28
|
+
slim Listen: .on(cb,{key?})->off · .off(key|cb) · .close() · .count()
|
|
29
|
+
```
|
|
30
|
+
External current getter example:
|
|
31
|
+
```
|
|
32
|
+
const [emit, listen] = listenStore<[Market]>({
|
|
33
|
+
current: () => store.node.has() ? [store.node.snapshot()] : undefined,
|
|
34
|
+
})
|
|
35
|
+
listen.on(cb, {current: true}) // current store value first, then future emit(...) events
|
|
36
|
+
listen.once(cb, {current: true}) // current store value once, or waits for one future event if current() returns undefined
|
|
37
|
+
```
|
|
38
|
+
```
|
|
39
|
+
isListenCallback(obj) -> boolean // duck-type a full Listen result
|
|
40
|
+
socketBuffer(...) · listenSnapshot(...) // snapshot/buffer adapters over a SocketSource
|
|
41
|
+
```
|
|
42
|
+
## 🔢 number formatting & math (full)
|
|
43
|
+
```
|
|
44
|
+
round(value, digits=0) // alias: NormalizeDouble
|
|
45
|
+
roundSig(value, {digitsPoint?, digitsR?, type?: 'max'|'min'}) // alias: NormalizeDoubleAnd
|
|
46
|
+
formatAuto(value, maxDigits=8) -> string // alias: DblToStrAuto (negative maxDigits = significant digits)
|
|
47
|
+
formatSig(value, {digitsPoint?, digitsR?, type?}) -> string // alias: DblToStrAnd (pairs with roundSig)
|
|
48
|
+
decimals(value, maxDigits=8, minDigits=0) -> number // alias: GetDblPrecision / GetDblPrecision2
|
|
49
|
+
gcd(a, b, digits?=8) | gcd(values: Iterable<number>, digits?=8) // alias: MaxCommonDivisor / MaxCommonDivisorOnArray
|
|
50
|
+
CorrelationRollingByBuffer(data) // rolling Pearson correlation over a ring buffer
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 🗺️ niche data structures
|
|
54
|
+
```
|
|
55
|
+
class MyMap<K extends {valueOf():number}, V> // sorted keys
|
|
56
|
+
.set/.get/.has/.delete/.clear · get size · .clone() // JS-Map surface
|
|
57
|
+
// alias: Set/Get/Contains/Remove/Clear/Count->set/get/has/delete/clear/size · Clone->clone
|
|
58
|
+
// (no keys()/values()/entries() — use getters: sortedKeys: readonly K[], Values: readonly V[])
|
|
59
|
+
class MyNumMap<V> extends MyMap<number,V> // also indexable: map[5]=v / map[5]
|
|
60
|
+
class StructMap<TKey, TResult> { set/get/has/keys/values/entries } // tuple / multi-part keys
|
|
61
|
+
class StructSet<TKey> { add/has/keys/values }
|
|
62
|
+
class ArrayMap<TKey extends number|string, TVal> extends StructMap<readonly TKey[], TVal>
|
|
63
|
+
class ArraySet<TKey extends number|string> extends StructSet<readonly TKey[]>
|
|
64
|
+
new VirtualItems<T>(getItem:(i)=>T, getLength:()=>number) // lazy array via Proxy; indexable+iterable, .length
|
|
65
|
+
class CCachedValue2<TKey extends [any,any], TVal> { getOrSet(key, ()=>val) } // recompute when any key element changes
|
|
66
|
+
class CObjectID<TObject, TOwner> { value:number; static getInfo/getObjectByOwner } // opaque typed ids
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## ⏳ cancellation & timers
|
|
70
|
+
```
|
|
71
|
+
class CancelablePromise<T> extends Promise<T> { constructor(exec, onCancel?); cancel(msg?); static resolve }
|
|
72
|
+
class CancelToken { get aborted; abort() } // poll-only (NOT a full AbortSignal). alias: isCancelled->aborted · cancel->abort
|
|
73
|
+
createCancellableTimer(interval_ms, onTimer:()=>boolean|void, onStop?) -> CancelablePromise<never> // onTimer()===false stops
|
|
74
|
+
createCancellableTaskWrapper<T>(task, isStopped, interval_ms=50)
|
|
75
|
+
class MyTimerInterval { constructor(period_ms, onTimer, onStop?); stop() }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## 🧭 object paths · linked list · byte stream
|
|
79
|
+
```
|
|
80
|
+
objectGet(obj, path:string[]) -> T // THROWS on missing/non-object segment (alias: objectGetValueByPath)
|
|
81
|
+
objectSet(obj, path, value) -> void (alias: objectSetValueByPath)
|
|
82
|
+
objectUnset(obj, path) -> boolean (alias: objectDeleteValueByPath)
|
|
83
|
+
deepEntries(obj, filter?) -> Generator<[key, value, path]> (alias: iterateDeepObjectEntries)
|
|
84
|
+
|
|
85
|
+
class CList<T> implements Iterable {
|
|
86
|
+
get first/last · get size (=length/count) · push(v)/unshift(v) -> node · pop()/shift() -> T|undefined
|
|
87
|
+
delete(valueOrNode)/deleteFirst()/deleteLast()/clear() · find(v)/findLast(v) · nodes()/reversedNodes()
|
|
88
|
+
} // immutable views: IList<T>, IListReadonly<T>, IListImmutable<T>
|
|
89
|
+
|
|
90
|
+
class ByteStreamW / ByteStreamR // pushNumber(value, type)/readNumber(type) over NumericTypes union
|
|
91
|
+
nullable(type: NumericTypes) // typed push*/read* (int8..uint64/float/double) stay as extended surface
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## 🎀 decorators
|
|
95
|
+
```
|
|
96
|
+
wrap(fn, { beforeParams?, modifyParams?, afterParams?, onResult?, modifyResult?, onCatch?, onFinally? }) -> (...args)=>R
|
|
97
|
+
// hooks around a call (sync or async-aware). NOTE the error hook is `onCatch` (not onError). alias: enhancedDecorator
|
|
98
|
+
around(fn, ([args, fn]) => R) -> (...args) => R // AOP around-advice, fn passed UN-CALLED (lodash _.wrap). alias: Transformer
|
|
99
|
+
// also @deprecated -> wrap/around: enhancedTransformer, Decorator, TransformerResult
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## ⏰ timeframes & periods
|
|
103
|
+
```
|
|
104
|
+
class TF { // S1, M1, H1, D1, ...
|
|
105
|
+
static get(name) -> TF|null · static getAsserted(name) -> TF · static fromSec(sec)
|
|
106
|
+
static createCustom(unit, count) · createCustomFromSec(sec) · readonly all · S1 S5 M1 M5 H1 D1 ...
|
|
107
|
+
get sec/msec/name // alias: fromName->get
|
|
108
|
+
}
|
|
109
|
+
class Period { get tf/index/startTime/endTime; static StartTimeForIndex(tf, index) }
|
|
110
|
+
class PeriodSpan · class CDelayer // deferred-run helper
|
|
111
|
+
durationToStr_h_mm_ss(ms) / _ms · durationToStrNullable(ms) // (alias: -> formatDuration)
|
|
112
|
+
toPrintObject(obj) // = convertDatesToStrings
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## 🎨 color
|
|
116
|
+
```
|
|
117
|
+
rgb(r, g, b) -> ColorString
|
|
118
|
+
hue(value=180, count=100, index=1) -> ColorString // distinct palette color (alias: colorGeneratorByCount)
|
|
119
|
+
hueRGB(value=180, count=100, index=1) -> [r,g,b] (alias: colorGeneratorByCount2)
|
|
120
|
+
toRGBA(str: ColorString) -> [r,g,b,a] | toRGBA(str: string) -> [r,g,b,a]|undefined (alias: colorStringToRGBA)
|
|
121
|
+
toColorString(str) -> ColorString // validates, else throws
|
|
122
|
+
isSimilarColors(c1, c2, maxDelta=32) -> boolean
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## 🖥️ console · proxy · id · rate-window · input
|
|
126
|
+
```
|
|
127
|
+
callerLine(lvl=0) -> "file:line:col func" // V8 caller frame (alias: __LineFile2; __LineFile/__LineFiles->callerLines)
|
|
128
|
+
callerLines(start=0, end=5) -> string[] // (alias: __LineFiles)
|
|
129
|
+
enable(flag=true) / disable() // clickable source links in console (IDE)
|
|
130
|
+
installProxyTracking() // call once at startup (browser fallback). isProxy(v)->boolean (alias: isProxyInit)
|
|
131
|
+
createIdPool() -> { next() -> number, release(id) } // reuses released ids
|
|
132
|
+
createRateWindow() -> { add(item), prune(type, ms?), sumWeight(type), readyAt(...), ...legacy } // alias: funcTimeW
|
|
133
|
+
rateWindow // shared default createRateWindow() instance (= FuncTimeWait)
|
|
134
|
+
SetAutoStepForElement(el, { minStep?, maxStep? }) // browser input
|
|
135
|
+
copyToClipboard(text) -> Promise<void> · GetEnumKeys(E) · isDate(v)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## ⚠️ errors
|
|
139
|
+
```
|
|
140
|
+
class MyError<D> extends Error { toJSON() -> tWire<D> } // wire-serializable error
|
|
141
|
+
toError = { ... } // build/normalize MyError from unknown
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## 🌐 rpc (full)
|
|
145
|
+
```
|
|
146
|
+
// servers
|
|
147
|
+
createRpcServerAuto(opts) // canonical: nested object -> typed client proxy (auto Listen handling)
|
|
148
|
+
// replay-transparent exposure: facade members that are replay listens (replayListen — brand-detected,
|
|
149
|
+
// opts.replay: false|'auto'|'force') are exposed with BOTH surfaces under the SAME key: the legacy plain-Listen
|
|
150
|
+
// path stays byte-for-byte (Pkt.MAP only grows additively), plus line/frameLine/since/keyframe/frame — so
|
|
151
|
+
// upgrading listen -> replayListen is a declaration-site-only change; replaySubscribe(client.func.key) works as is.
|
|
152
|
+
// opts.replayOpts {pending?, highWater?, lowWater?, pollMs?} arms the per-connection lag gate on frameLine
|
|
153
|
+
// (consumer picks policy 'queue'|'frame' at subscribe time); the replay `line` is never throttled (no seq holes).
|
|
154
|
+
createRpcServer(opts) // lower-level core
|
|
155
|
+
createRpcServerAutoDetect(opts) // + legacy/v2 protocol auto-detection (createRpcServerAutoWithProtocolDetection)
|
|
156
|
+
createRpcServerInProc(...) // in-process fast path (no socket)
|
|
157
|
+
// clients
|
|
158
|
+
createRpcClientHub(opts) + rpc // multiplexing client hub: connect(token)/reauth(token)/onConnect
|
|
159
|
+
// alias: hub.setToken->connect
|
|
160
|
+
client members: func (proxy) · strict (schema-safe) · schema() · auth() · reauth() · onDisconnect()
|
|
161
|
+
close(reason?, {socketAlive?}) · ready() · init(obj?) · api.subscriptions()
|
|
162
|
+
// alias: dispose->close · readyStrict->ready · initStrict->init
|
|
163
|
+
noStrict(obj) / isNoStrict(obj) // dynamic (no-schema) subtree
|
|
164
|
+
endCallback(fn) // alias: rpcEndCallback
|
|
165
|
+
// subscription primitives (rare/manual; createRpcServerAuto/createRpcClientHub are the normal path)
|
|
166
|
+
listenSocket(parent, opts?) · listenSocketFirst · listenSocketAll · listenSocketSmart
|
|
167
|
+
deepListenFirst(obj, opts?) · deepListenAll · deepListenSmart
|
|
168
|
+
RPC Listen surface on client: stream.on(cb)->off · stream.once(cb)->off · stream.close()
|
|
169
|
+
// typed projection: client.func as unknown as DeepSocketListen<ServerFacade> (usually hidden behind a local webListen(client) helper).
|
|
170
|
+
// replay members project as ReplaySocketListen<Z> automatically (legacy surface + line/frameLine/since/keyframe/frame,
|
|
171
|
+
// tuples preserved end-to-end) — client.func.key passes to replaySubscribe as is, no casts.
|
|
172
|
+
// The same projection is built into BOTH typed-client paths (ClientAPIAll/ClientAPIStrict): on a plain rpc<T>() client
|
|
173
|
+
// replay members are already ReplaySocketListen on client.func/client.strict — no webListen and no casts for them
|
|
174
|
+
// (plain Listen members still need the DeepSocketListen projection).
|
|
175
|
+
// off is callable + thenable: off() unsubscribes; await off waits for stream end.
|
|
176
|
+
// *First/*All/*Smart differ only in callback arity: first arg / all args / single-vs-tuple smart.
|
|
177
|
+
matchKeys(a,b) · matchKeysList(a, keys) · deepMapByKeys · deepMapByKeysList
|
|
178
|
+
// wire serialization (rpc-walk): Date/Map/Set/RegExp/BigInt are marked+restored; functions -> callback refs.
|
|
179
|
+
// TypedArray/DataView/Buffer/ArrayBuffer pass through as BINARY leaves (socket.io carries them natively;
|
|
180
|
+
// never rebuilt into {0:…,1:…} dicts — raw canvas/video byte payloads are wire-safe and cheap).
|
|
181
|
+
RpcLimits (opt, per server/client): maxDepth 32 · maxKeys 1000 · maxArgs 64 · maxArrayLen 10k
|
|
182
|
+
· maxStringLen 1M · maxCallbacks 100 · maxPathLen 16 · maxBinaryLen 8MB (bytes per binary leaf)
|
|
183
|
+
// modes: func (proxy) · strict (schema-safe) · pipe (whole chain in one packet) · space (fire-and-forget)
|
|
184
|
+
// legacy (oldCommonsServer.ts, @deprecated forwarders onto oldСommonsServerMini — identical wire):
|
|
185
|
+
// funcPromiseServer->promiseServer · funcForWebSocket->wsWrapper · funcScreenerClient2->createClientProxy
|
|
186
|
+
// CreatAPIFacadeServerOld->createAPIFacadeServer ; CreatAPIFacadeClientOld & funcPromiseServer2 kept as-is
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### RPC dynamic maps: prefer `noStrict` for personal/runtime keys
|
|
190
|
+
Use `noStrict(obj)` for user-scoped or runtime-keyed objects whose children are not a stable API schema: strategy maps, account maps, ORM/DB proxies, per-session private objects. The name is exactly `noStrict`.
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
return {
|
|
194
|
+
strategies: noStrict(strategyByName),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await client.func.strategies["mystrategy.2020"].start()
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Contract:
|
|
201
|
+
- `noStrict` stops schema walking and routeMap indexing below that object.
|
|
202
|
+
- It is not an access-control boundary and does not bypass safe-key/path limits. Validate user-owned names in your facade if they are security-sensitive.
|
|
203
|
+
- RPC paths are arrays of string segments. `"mystrategy.2020"` is one segment, so the call above is `["strategies", "mystrategy.2020", "start"]`, not `["strategies", "mystrategy", "2020", "start"]`.
|
|
204
|
+
- The failure mode to avoid is treating `path.join(".")` as identity: `["a.b", "c"]` and `["a", "b", "c"]` both display as `a.b.c` but are different RPC paths.
|
|
205
|
+
- Static dotted keys are also supported: `api["a.b"].c` is distinct from `api.a.b.c`. Internal route/listen/cache identity must stay lossless; dotted strings are only a debug display form.
|
|
206
|
+
- If a branch is a fixed public API, keep it strict. If a branch is a personal/dynamic keyspace, wrap that branch in `noStrict` instead of trying to publish all current keys as schema.
|
|
207
|
+
|
|
208
|
+
## 🎙️ Media over socket — browser capture as binary Listen
|
|
209
|
+
> `import { Media } from "wenay-common2"` or `import * as Media from "wenay-common2/media"`.
|
|
210
|
+
> The hot path event is ONE `Uint8Array`: fixed 40-byte common2 media header + raw payload. No JSON envelope.
|
|
211
|
+
```
|
|
212
|
+
Media.createAudioSource(opts?) -> [emit, listen] & control
|
|
213
|
+
Media.createVideoSource(opts?) -> [emit, listen] & control
|
|
214
|
+
Media.encodeMediaFrame(meta, payload) -> Uint8Array
|
|
215
|
+
Media.decodeMediaFrame(frame) -> {kind, codec, seq, tMono, payload, sampleRate?, channels?, nSamples?, width?, height?}
|
|
216
|
+
|
|
217
|
+
control: start() -> Promise<MediaSourceState> · stop() · getStats() · setDevice(id) · listDevices()
|
|
218
|
+
state: 'idle'|'requesting'|'live'|'denied'|'no-device'|'error'
|
|
219
|
+
```
|
|
220
|
+
Audio source:
|
|
221
|
+
- default `mode:'pcm'`, `format:'int16'`, raw PCM payload; uses `AudioWorklet` when available and falls back to `ScriptProcessor` only when the browser cannot run a worklet.
|
|
222
|
+
- `mode:'record'` uses `MediaRecorder` chunks (`webm-opus`) for record/upload flows, not live STT.
|
|
223
|
+
- `getStats().rms` gives a VU-meter signal; permission denied/no device returns typed state, not a thrown public failure.
|
|
224
|
+
|
|
225
|
+
Video source:
|
|
226
|
+
- default snapshots, not a 30fps video stream: JPEG, `fps` default 3, `quality` default 0.82.
|
|
227
|
+
- each frame carries absolute image bytes, so `replay:true` can safely keep the latest frame for lag recovery.
|
|
228
|
+
- capture is hidden-tab-proof by default (Chrome throttles hidden tabs three ways, each stage has its own escape): the tick comes from a Blob-worker timer (in-page `setInterval` drops to ~1/s), the frame comes from `ImageCapture.grabFrame()` when available (a hidden `<video>` stops painting; `<video>->canvas` stays as the fallback), and JPEG encode runs in a worker over a transferred `ImageBitmap`, returning a transferred `ArrayBuffer` — never a structured-cloned frame (main-thread `convertToBlob` is gated to ~1s per call when hidden). `worker: false` opts out of all three into the plain in-page path.
|
|
229
|
+
- one explicit dimension (`width` or `height`) scales the other proportionally from the track resolution, downscale-only; pass both to force an exact size. `grabFrame`'s ~50ms serial latency caps the pipeline around ~15-20fps regardless of `fps`.
|
|
230
|
+
|
|
231
|
+
Viewer helpers (`media-view`): the consumer side of any media line (local pair or RPC surface).
|
|
232
|
+
- `attachVideoCanvas(line, canvas, {createBitmap?, onError?})` — per-frame codec/size come from the 40-byte header, canvas resizes to follow; decode overload is busy-skipped (keep-latest, `stats().frames` vs `stats().drawn` shows the gap); `createBitmap` injects a custom decoder (tests, OffscreenCanvas pipelines).
|
|
233
|
+
- `attachAudioPlayer(line, {maxBacklogSec? = 0.35, audioContext?, onError?})` — pcm16/float32 through a sequential playhead; a backlog past `maxBacklogSec` is dropped and the playhead rebases near "now" (live beats lossless; `stats().dropped` counts rebases). `enable()` must come from a user gesture (browser autoplay rules); `audioContext` injects a factory for tests/custom routing.
|
|
234
|
+
- `pipeMediaPublish(line, publish, {stamp? = true, onError?})` — fire-and-forget pipe into an RPC call; the default `Date.now()` stamp is what viewer `stats().ageMs` measures against. Both attach helpers also expose `stats().perSec` (rolling 1s rate).
|
|
235
|
+
- Oracle: `replay/media-view.test.ts`.
|
|
236
|
+
|
|
237
|
+
Replay/RPC wiring:
|
|
238
|
+
```ts
|
|
239
|
+
const audio = Media.createAudioSource({sourceId: 'mic'}) // plain lossless queue Listen
|
|
240
|
+
const video = Media.createVideoSource({sourceId: 'cam', fps: 2, replay: true})
|
|
241
|
+
|
|
242
|
+
createRpcServerAuto({
|
|
243
|
+
socket, socketKey: 'media',
|
|
244
|
+
object: {audio: audio[1], video: video[1]},
|
|
245
|
+
replayOpts: {highWater: 64, lowWater: 8},
|
|
246
|
+
})
|
|
247
|
+
```
|
|
248
|
+
`replay:true` makes the returned listen a `Replay.replayListen` surface before capture emits into it, so `createRpcServerAuto` brand-detects it and exposes legacy + replay under the same key. Defaults differ by media kind: audio replay is a sacred queue (`history:1024`, no keyframe/frame, do not drop samples); video replay is keep-latest (`history:256`, `current:'last'`, `frame` returns the newest covered frame). Pass `replay:{...}` for custom history/current/frame.
|
|
249
|
+
|
|
250
|
+
Backpressure rule: audio consumers should use the default queue policy unless the app explicitly accepts loss. Video consumers can use `Replay.replaySubscribe(remote.video, cb, {policy:'frame'})`; a slow socket drains to the latest frame instead of accumulating stale images. The binary frame itself is RPC-safe because `rpc-walk` passes `TypedArray`/`ArrayBuffer` leaves through natively and applies `maxBinaryLen`.
|
|
251
|
+
|
|
252
|
+
WebRTC future contract:
|
|
253
|
+
- `transport:'socket'` is the implemented default today.
|
|
254
|
+
- `transport:'webrtc'` is reserved and currently reports `state:'error'` on `start()`; it is not a hidden second transport.
|
|
255
|
+
- Future WebRTC support must be explicit opt-in for sub-200ms human duplex. Signaling belongs on the existing socket/RPC control channel (offer/answer/ICE), and backend/AI access requires an SFU that re-emits media bytes into the same `Media` Listen/replay surface. Downstream RPC/replay/store consumers must not change.
|
|
256
|
+
|
|
257
|
+
Oracle: `npx ts-node replay/media-socket.test.ts` checks header decode, plain Listen shape, `replay:true`, typed no-device state in Node, and real Socket.IO binary delivery.
|
|
258
|
+
|
|
259
|
+
## 📈 exchange — params (`CParams`)
|
|
260
|
+
```
|
|
261
|
+
class CParams / CParamsReadonly implements IParams
|
|
262
|
+
toValues(params) -> SimpleParams // IParams -> plain enabled values (alias: GetSimpleParams)
|
|
263
|
+
fromValues(infos, values) -> IParams // inverse (alias: mergeParamValuesToInfos)
|
|
264
|
+
isSimpleParams(params) -> boolean // (isSimpleParams2 @deprecated)
|
|
265
|
+
isParamBase(p) · isParamGroup(p) · isParamGroupOrArray(p)
|
|
266
|
+
enableAllParams(params, enabled=true) -> clone
|
|
267
|
+
// types: IParam (the union) + IParamBase are the entry points; the per-flavour IParamNum/IParamEnum/IParamTime*/...
|
|
268
|
+
// and *Readonly twins exist but read the union — wrap with ReadonlyFull<T> rather than the *Readonly aliases.
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## 📈 exchange — bars (`Bars`)
|
|
272
|
+
```
|
|
273
|
+
class OHLC · class CBar extends CBarBase (IBar)
|
|
274
|
+
class CBars (IBarsImmutable) · class CBarsMutable / CBarsMutableExt (IBarsExt)
|
|
275
|
+
.push(bars|bar) // append (alias: Add)
|
|
276
|
+
.updateLast(bar) · .addTick(tick) · .addTicks(ticks) (alias: AddTick/AddTicks)
|
|
277
|
+
createRandomBars(tf, startTime, endTime|count, startPrice?, volatility?, tickSize?) -> CBars // alias: CreateRandomBars
|
|
278
|
+
class CTimeSeries<T=number> (ITimeseries) · CTimeSeriesReadonly<T>
|
|
279
|
+
findBarsShallow(srcBars, barsToFind) -> number
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## 📈 exchange — market data (`MarketData`)
|
|
283
|
+
```
|
|
284
|
+
class CQuotesHistory
|
|
285
|
+
.get(tf) -> IBarsImmutable|null // build-on-demand (alias: Bars(tf))
|
|
286
|
+
class CQuotesHistoryMutable / CQuotesHistoryMutable2 extends CQuotesHistory
|
|
287
|
+
.append(bars[, tf]) (alias: AddEndBars) · .prepend(bars[, tf]) (alias: AddStartBars)
|
|
288
|
+
.addTicks(ticks) (alias: AddTicks; replaces last bar) · AddNewTicks (strict append-only, rare)
|
|
289
|
+
.deleteBefore(time)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## 🧩 server / socket helpers
|
|
293
|
+
```
|
|
294
|
+
SocketServerHook(opt?) · WebSocketServerHook(hook, params?, disconnect?) // server-side socket wiring
|
|
295
|
+
saveKeyValue({ dirDef, key? }) -> SaveKeyValueStore // fs-backed key/value store
|
|
296
|
+
createWebhookServer(params) · createWebhookClient(opts) · buildSelfWebhookUrl(ip, raw)
|
|
297
|
+
createSignatureFunction(hmacCreator) -> SignatureFunction
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## 🧬 type utilities (`core/type.ts`, `core/BaseTypes.ts`)
|
|
301
|
+
```
|
|
302
|
+
Nullable<T> · PartialBy<T,K> · RequiredBy<T,K> · StringKeys<T> · ObjectEntries<T>
|
|
303
|
+
ArrayElementType<T> · TupleFirst<T>/TupleLast<T> · MapKeyType<T>/MapValueType<T> · ResolvedReturnType<T>
|
|
304
|
+
ReadonlyFull<T> · MutableFull<T> · Mutable<T> · Immutable<T> · const_Date
|
|
305
|
+
KeysByType<T,P> · PickTypes<T,P> · OmitTypes<T,E> · ReplaceKeyType<S,K,New>
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## 🔁 Observe Store — path node facade + simple mirror sync
|
|
309
|
+
> Public v2 store API: `import { Observe } from "wenay-common2"` or `import * as Observe from "wenay-common2/observe"`.
|
|
310
|
+
> `Observe.createStore(initial)` wraps the fact-based `reactive` core with typed path nodes. `state` is the plain-feeling data object; `node` is the subscribable path tree. Transport stays simple: selected snapshots, not public diffs.
|
|
311
|
+
```
|
|
312
|
+
type Market = {data: {BTC?: number; ETH?: number}; meta: {status?: string}}
|
|
313
|
+
const store = Observe.createStore<Market>({data: {BTC: 1, ETH: 2}, meta: {status: 'ok'}})
|
|
314
|
+
|
|
315
|
+
store.state.data.BTC = 3 // local backend/frontend code writes normally
|
|
316
|
+
store.node.data.BTC.get() -> number | undefined
|
|
317
|
+
store.node.data.BTC.replace(4) // writes this path; set(v) = deprecated alias
|
|
318
|
+
store.node.data.BTC.on((v, ctx) => {}, {current: true}) // primitive leaf; ctx.path / ctx.pathString
|
|
319
|
+
store.node.data.BTC.once(cb, {current: true}) // current value counts as the event
|
|
320
|
+
store.node.data.on((data, ctx) => {}, {current: true, drain: 50})
|
|
321
|
+
store.on((whole) => {}, {current: true}) // whole store snapshot
|
|
322
|
+
store.count() // local subscribers through StoreNode
|
|
323
|
+
```
|
|
324
|
+
Typed masks / multiple subscriptions:
|
|
325
|
+
```
|
|
326
|
+
const sel = store.update({data: {BTC: true, ETH: true}, meta: {status: true}}, {current: true})
|
|
327
|
+
sel.get() -> {data: {BTC, ETH}, meta: {status}}
|
|
328
|
+
sel.on((snap, ctx) => {}) // aggregated selected snapshot; coalesced by default
|
|
329
|
+
sel.once((snap) => {}, {current: true})
|
|
330
|
+
sel.onEach((value, ctx) => { ctx.pathString }) // one event per SELECTED path, with route (explicit masks only)
|
|
331
|
+
```
|
|
332
|
+
`store.each()` — extended notes (the per-key feed itself: signature, expansion contract and the
|
|
333
|
+
canonical example live in wenay-common2.md):
|
|
334
|
+
- A key whose primitive value is unchanged by a root replace does not fire (the set trap skips
|
|
335
|
+
`Object.is`-equal writes); object values always fire — replay patches apply fresh snapshot copies.
|
|
336
|
+
- `each({depth})` is reserved: only `1` (top-level keys) is accepted today, anything else throws.
|
|
337
|
+
- `ctx` is `{path: [key]}`. `key` is typed `string`; symbol top-level keys pass through at runtime as-is.
|
|
338
|
+
- The `update(true).onEach` dev warn fires once per process (explicit key masks never warn —
|
|
339
|
+
`onEach` stays correct for them).
|
|
340
|
+
- `{'*': true}` is not a wildcard — it subscribes a literal `'*'` key (zero calls, no warn).
|
|
341
|
+
|
|
342
|
+
Backend expose + frontend mirror:
|
|
343
|
+
```
|
|
344
|
+
const facade = { market: Observe.exposeStore(store) }
|
|
345
|
+
// createRpcServerAuto({object: facade, ...}) exposes: get(mask?), changed/changedPaths Listen, set/replace(path,value)
|
|
346
|
+
|
|
347
|
+
const mirror = Observe.createStoreMirror<Market>(api.market, {data: {}, meta: {}})
|
|
348
|
+
const stopSync = await mirror.sync(
|
|
349
|
+
{data: {BTC: true, ETH: true}, meta: {status: true}},
|
|
350
|
+
{current: true, drain: 250}, // default partial:true uses changedPaths when available
|
|
351
|
+
)
|
|
352
|
+
mirror.node.data.BTC.on(v => render(v), {current: true})
|
|
353
|
+
stopSync()
|
|
354
|
+
```
|
|
355
|
+
Runnable example: `npx tsx observe/store-mirror.example.ts`.
|
|
356
|
+
Optional push-data channels (explicit high-frequency mode; usually choose one):
|
|
357
|
+
```
|
|
358
|
+
type StorePatch = {path: PropertyKey[]; value: any; exists: boolean}
|
|
359
|
+
type StoreChangedData = {mask: any; data: any}
|
|
360
|
+
|
|
361
|
+
const pushed = Observe.exposeStore(store, {push: true})
|
|
362
|
+
|
|
363
|
+
// Raw manual wiring: patch event carries one dirty path's current value.
|
|
364
|
+
pushed.patches!.on((patch) => {
|
|
365
|
+
Observe.applyStorePatch(mirror, patch) // exists:false means delete path
|
|
366
|
+
})
|
|
367
|
+
Observe.applyStorePatches(mirror, patches) // batch variant: apply an array of patches in order
|
|
368
|
+
|
|
369
|
+
// Batch-shaped dirty data: one event has dirty mask + snapshot for that mask.
|
|
370
|
+
pushed.changedData!.on(({mask, data}) => {
|
|
371
|
+
Observe.applyStoreMask(mirror, mask, data)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
// Mirror helpers keep the client's selected mask and apply only its intersection
|
|
375
|
+
// with the global push event. current:true still does one initial get(mask).
|
|
376
|
+
const stopPatchSync = await mirror.syncPatches(
|
|
377
|
+
{data: {BTC: true}, meta: {status: true}},
|
|
378
|
+
{current: true, drain: 50},
|
|
379
|
+
)
|
|
380
|
+
const stopDataSync = await mirror.syncChangedData(
|
|
381
|
+
{data: {BTC: true}, meta: {status: true}},
|
|
382
|
+
{current: true, drain: 50},
|
|
383
|
+
)
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Declarative manager over store resources:
|
|
387
|
+
```ts
|
|
388
|
+
const manager = Observe.createStoreManager({
|
|
389
|
+
market: Observe.managedStore.mirror({
|
|
390
|
+
remote: api.market,
|
|
391
|
+
initial: {data: {}, meta: {}},
|
|
392
|
+
mask: {data: {BTC: true}, meta: {status: true}},
|
|
393
|
+
tags: ['bootstrap', 'route:main'],
|
|
394
|
+
priority: 10,
|
|
395
|
+
sync: {mode: 'pull', opts: {current: true, drain: 'micro'}},
|
|
396
|
+
}),
|
|
397
|
+
rows: Observe.managedStore.offline({
|
|
398
|
+
remote: api.rows.replay,
|
|
399
|
+
initial: {},
|
|
400
|
+
storage: indexedDbStorage,
|
|
401
|
+
storageKey: 'rows',
|
|
402
|
+
tags: ['grid'],
|
|
403
|
+
priority: ({usage}) => usage?.weight ?? 0,
|
|
404
|
+
syncOpts: {staleMs: 30_000},
|
|
405
|
+
}),
|
|
406
|
+
video: Observe.managedStore.replay({
|
|
407
|
+
remote: api.video.replay,
|
|
408
|
+
initial: {},
|
|
409
|
+
explicitOnly: true,
|
|
410
|
+
large: true,
|
|
411
|
+
}),
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
manager.plan() // excludes explicitOnly/large by default
|
|
415
|
+
manager.plan({includeExplicit: true, includeLarge: true})
|
|
416
|
+
await manager.startPlanned({tags: ['bootstrap']})
|
|
417
|
+
manager.touch('rows', 3) // records local usage for future scoring
|
|
418
|
+
await manager.start('video', {explicit: true})
|
|
419
|
+
manager.stopAll()
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Contract:
|
|
423
|
+
- `node` subscriptions are address-based, so `store.state.data = {BTC: 10}` keeps `store.node.data.BTC` subscriptions alive.
|
|
424
|
+
- Primitive, missing, and later-created paths are subscribable.
|
|
425
|
+
- `{current:true}` emits only when a value exists; absent paths wait for the first value.
|
|
426
|
+
- `drain` is per subscription/sync. Branch subscribers receive whole branch snapshots; mask `.on()` receives the selected snapshot; `.onEach()` receives `(value, ctx)` with route; `store.each()` receives `(key, value, ctx)` per changed top-level key.
|
|
427
|
+
- `pathString` is human-readable; internal route identity is collision-safe for dotted keys and distinct `Symbol()` keys.
|
|
428
|
+
- Mirror sync uses backend `changedPaths` when present: it pulls `selected mask ∩ dirty paths`; with no `changedPaths` or `{partial:false}` it falls back to `changed -> get(mask)`. UI subscribes to the local mirror store.
|
|
429
|
+
- Default `sync` is still pull-after-notify: event is light, reconnect is a fresh `get(mask)`, and each client owns its mask.
|
|
430
|
+
- `{push:true}` adds global push-data channels. `patches` emits `{path,value,exists}` per dirty path; `changedData` emits `{mask,data}` per dirty batch. They are separate from `changed`, so old clients and default mirror behavior do not change.
|
|
431
|
+
- `syncPatches` and `syncChangedData` are explicit mirror modes. They require the matching remote channel, do one initial `get(mask)` unless `{current:false}`, then apply pushed events without per-change round-trip.
|
|
432
|
+
- Push events are global, not per-subscriber mask streams. The mirror intersects each event with its own selected mask; a broad branch replace only updates selected leaves locally.
|
|
433
|
+
- Prefer default `sync` until round-trip cost or latency matters. Push mode sends more data in the event and reconnect should still resync with a fresh current snapshot.
|
|
434
|
+
- JSON/RPC transports should use JSON-safe path keys for push channels; `Symbol` paths are local-only even though the in-memory store can address them.
|
|
435
|
+
- Dirty paths are facts about changed object routes: add key, delete key, or deep set. Array mutation dirties the whole array branch; no public splice/index diff is promised.
|
|
436
|
+
- `snapshot()`/`update().get()` walk raw targets (`toRaw`), so a snapshot of a cold store creates no lazy reactive nodes.
|
|
437
|
+
- Writing a reactive proxy back into state stores its raw value (no reactive-in-reactive).
|
|
438
|
+
- Mirror `sync` pulls are chained sequentially: a slow (stale) response never overwrites a newer one.
|
|
439
|
+
- A slot keeps its proxy identity across an array↔object replace, so `Array.isArray` on a captured proxy reflects the original shape; JSON serialization follows the current value. Use `toRaw()` when the real shape matters.
|
|
440
|
+
|
|
441
|
+
Run coverage:
|
|
442
|
+
```bash
|
|
443
|
+
npx tsx observe/listen-store.test.ts
|
|
444
|
+
npx tsx observe/store.test.ts
|
|
445
|
+
npx tsx observe/store-manager.test.ts
|
|
446
|
+
npx tsx observe/store-mirror.example.ts
|
|
447
|
+
```
|
|
448
|
+
## 🎞️ Replay — snapshot + sequenced delta line
|
|
449
|
+
> Keyframe + seq-numbered deltas + recovery via a fresh keyframe — one pattern for store sync,
|
|
450
|
+
> ticks and video-like frame streams. `import { Replay } from "wenay-common2"` or
|
|
451
|
+
> `import { ... } from "wenay-common2/replay"`; the store pair lives in `Observe`.
|
|
452
|
+
> Design: `REPLAY-PLAN.md`; oracles: `replay/` (import the canonical `src/` modules).
|
|
453
|
+
```
|
|
454
|
+
withReplayListen(base, {current?, frame?, history?, getSince?, onJournal?, now?, staleMs?, onStale?}) · replayListen // layer A: journal {seq, ts, event}; on(cb, {since, onSeq}); head()/getSince()/keyframe()/hasKeyframe · isStale()/lastTs()
|
|
455
|
+
// FRAME MODEL — one method, two sources, three triggers. frame(sinceSeq, hint?) -> envelopes bringing a consumer
|
|
456
|
+
// from sinceSeq to head, as compact as the line allows; default = exact journal tail ?? keyframe.
|
|
457
|
+
// Source 1 (keyframe): `current` — full state SAMPLED from the owner of truth (never computed from deltas);
|
|
458
|
+
// sugar `current: 'last'` — single-entity lines: keyframe = last journaled envelope, no hand-kept state.
|
|
459
|
+
// Source 2 (mini-frame): `frame` lambda — gets the raw tail, returns a state-equivalent compact
|
|
460
|
+
// (last-per-entity, gap aggregate...); cost ~ touched entities, wins over keyframe while the journal covers.
|
|
461
|
+
// Line classes FOLLOW from declared lambdas (no mode flags): current+frame = condensable; current only =
|
|
462
|
+
// snapshot-recoverable; neither = sacred queue — full tail only, evicted -> frame() THROWS (loud, never silent loss).
|
|
463
|
+
// Triggers: reconnect (`since`), client pull (own timer — replaces any server-side interval mode), server gate drain.
|
|
464
|
+
// The transport sees ONLY seq; entity keys/skip rules live in producer lambdas (hint = opaque per-subscriber pass-through).
|
|
465
|
+
exposeReplay(replay) <-> replaySubscribe(remote, cb, {since?, onSeq?, staleMs?, onStale?, skewMs?, now?, policy?, hint?}) -> off // wire pair over the EXISTING rpc: line = plain Listen, since/keyframe/frame = plain methods
|
|
466
|
+
// NORMAL PATH: createRpcServerAuto exposes replay listens automatically (see rpc section) — exposeReplay stays
|
|
467
|
+
// as the manual/custom-transport path. replaySubscribe prefers `frame` when the server has it (one round trip,
|
|
468
|
+
// server picks tail/mini-frame/keyframe; sacred throw -> onError), falls back to since/keyframe on old servers.
|
|
469
|
+
// policy: 'queue' (default — socket buffers everything, nothing skipped) | 'frame' (subscribes frameLine when
|
|
470
|
+
// present: on lag the server drops and recovers via frame(lastSent) — mini-frame, no backlog). A non-envelope
|
|
471
|
+
// on the line (RPC_STOP — e.g. the gate's loud sacred failure) surfaces via onError + off, never silence.
|
|
472
|
+
// hint reaches the frame lambda on catch-up and on every explicit frame(seq, hint) call (pull); the push-gate's
|
|
473
|
+
// drain recovery uses the line's DEFAULT condensation — client-specific rules/pace = the pull path.
|
|
474
|
+
// off.ready (catch-up done) · off.seq() (reconnect point) · off.isStale()/off.lastTs(); reconnect = call again with {since: prev.seq()}
|
|
475
|
+
replayRouteSubscribe(remote, cb, {label?, since?, onSeq?, onError?, onRoute?}) -> off & {ready, switch(nextRemote, {label?, since?, reset?, policy?, hint?}), seq(), label(), active()}
|
|
476
|
+
// transport hand-off helper: old route remains live, replacement subscribes+catches up from seq, then old closes; overlap is seq-deduped. Use for relay -> direct and direct -> relay over any ordered ReplayRemote.
|
|
477
|
+
// DELIVERY CONTRACT (guaranteed, not best-effort): the subscriber's cb sees ONE uniform stream —
|
|
478
|
+
// first delivery = the snapshot (keyframe as an event of the SAME type; store: root patch),
|
|
479
|
+
// then only strictly-newer events, seq-ascending, no gaps, no dups. Live events racing ahead of the
|
|
480
|
+
// keyframe over the wire are queued during catch-up and seq-deduped — they can NEVER arrive first.
|
|
481
|
+
// With {since: K}: same fold, journal tail after K instead of a keyframe (evicted -> keyframe fallback,
|
|
482
|
+
// visible to the client as a seq jump > +1). Requires an ORDERED transport (socket.io / TCP / in-proc).
|
|
483
|
+
// Net effect: one client fold `state = apply(state, event)` handles cold start, reconnect,
|
|
484
|
+
// conflation recovery and archive playback identically — snapshot is not a special case.
|
|
485
|
+
// FRESHNESS (staleMs/onStale — an option, not consumer boilerplate): delivery is consistent but silent
|
|
486
|
+
// about staleness. Two failure modes it would otherwise hide: a SILENT LINE (producer died, line stays
|
|
487
|
+
// open, no envelopes) and a STALE KEYFRAME (arrives now, but its ts is old — "fresh over the wire" while
|
|
488
|
+
// minutes stale). onStale({stale, lastTs, age}) is edge-triggered BOTH ways, never a repeating alarm.
|
|
489
|
+
// Producer side: no journal event for staleMs -> stale; the timer exists only with onStale and arms after
|
|
490
|
+
// the first event (a cold line stays free); isStale()/lastTs() are lazy getters, no timer needed.
|
|
491
|
+
// Client side, two signals: ARRIVAL GAP (local clock, the only timer — catches the silent line regardless
|
|
492
|
+
// of clock skew) + ENVELOPE-TS AGE checked at delivery (producer clock — a stale keyframe reports stale
|
|
493
|
+
// IMMEDIATELY; clock-skew caveat: producer/client clocks may disagree, skewMs tolerance absorbs it, default 0).
|
|
494
|
+
// A since-tail's historical ts never flaps mid-catch-up (one assessment after handover); off() disarms the timer.
|
|
495
|
+
createRouteCoordinator({connect, policy?, shadow?, catchUpTimeoutMs?}) -> coordinator // policy-gated relay <-> direct routing shell over pure connectors (ROADMAP 0.1)
|
|
496
|
+
// LAYERING: a CONNECTOR is a pure transport (no route decisions) — {info: {label, kind: 'relay'|'direct',
|
|
497
|
+
// binary?, ordered?, reliable?}, open() -> ReplayRemote, close(), state(), metrics?() -> {rtt?, pending?},
|
|
498
|
+
// onFail?}; the COORDINATOR alone owns promotion/fallback (state machine + policy); data continuity is
|
|
499
|
+
// replayRouteSubscribe underneath, so ANY route change is gap-free and dup-free by the seq contract.
|
|
500
|
+
// WebRTC/NAT is deliberately absent here: a future datachannel is just another RouteConnector.
|
|
501
|
+
// connect(ref, kind) -> RouteConnector — transport factory per pair and kind (called per activation).
|
|
502
|
+
// policy hooks run BEFORE any transport action; absent hook = allowed, present hook must return true:
|
|
503
|
+
// canDirect (may the pair attempt direct) · mustRelay (force relay: NDA/audit/moderation — beats canDirect)
|
|
504
|
+
// · mustShadowRelay (direct payload + relay audit copy) · canExposeEndpoint (may signaling reveal
|
|
505
|
+
// endpoint/session material) · canReinterpose (may relay step back into the path).
|
|
506
|
+
// coordinator.pair(a, b) -> link (symmetric key: pair(a,b) == pair(b,a)) · state/promoteDirect/
|
|
507
|
+
// reinterposeRelay/fallback/block(pairOrKey, ...) · onRoute(cb) -> off (all transitions, all pairs)
|
|
508
|
+
// · pairs() · close()
|
|
509
|
+
// link: .subscribe(cb, opts?) -> off & {ready, seq(), label(), active()} // the pair's data: a replay stream
|
|
510
|
+
// that survives every route change; facade/authority semantics never learn the transport switched
|
|
511
|
+
// .promoteDirect({timeoutMs?, reason?}) -> Promise<{ok, state, reason?}> // policy denial and transport
|
|
512
|
+
// failure are EXPECTED OUTCOMES (result object), not exceptions; ops are serialized per link
|
|
513
|
+
// .reinterposeRelay(reason?) / .fallback(reason?) / .block(reason?) · .state() · .label() · .metrics() · .close()
|
|
514
|
+
// STATE MACHINE: relay -> direct:connecting -> direct | direct+shadowRelay; direct -> relay:reinterposing -> relay;
|
|
515
|
+
// failed/slow direct (timeoutMs) -> fallback (relay kept live the whole time, switch failed loudly);
|
|
516
|
+
// direct onFail (endpoint revoked, link died) -> auto fallback: close direct, resume relay from seq;
|
|
517
|
+
// any state -> blocked (terminal: subs closed, new subscribe throws, promote denied).
|
|
518
|
+
// direct+shadowRelay: payload rides direct while deps.shadow(ref, ...ev) receives the relay audit copy,
|
|
519
|
+
// starting from the consumers' seq coordinate — the switch window never escapes the audit.
|
|
520
|
+
// Acceptance oracle: replay/route-coordinator.test.ts (fake in-process relay/direct connectors).
|
|
521
|
+
createSignalHub({authorize?}) -> {register(account) -> {send, signals, close}, revoke(pair, accounts, reason?), accounts(), close()}
|
|
522
|
+
// WebRTC signaling over the EXISTING control channel: the port shape {send, signals} is a function +
|
|
523
|
+
// Listen — exactly what createRpcServerAuto exposes, so the relay socket IS the signaling wire
|
|
524
|
+
// (per connection: const port = hub.register(account); object = {send: port.send, signals: port.signals}).
|
|
525
|
+
// authorize(env) is the SERVER-side canExposeEndpoint point — endpoint/session material is revealed only
|
|
526
|
+
// past it; client-side coordinator policy stays advisory. from-spoofing is cut at the port.
|
|
527
|
+
// SignalEnvelope = {type: 'offer'|'answer'|'ice'|'revoke'|'close' | 'ring'|'accept'|'decline'|'hangup',
|
|
528
|
+
// pair, from, to, sdp?, candidate?, session?, reason?} — session is opaque auth material (the wire
|
|
529
|
+
// never looks inside). The call types (peer-call) ride the SAME hub: routing is by `to` only, webrtc
|
|
530
|
+
// connectors filter by pair+type (no interference), and authorize(env) sees call envelopes too —
|
|
531
|
+
// one server-side policy point for endpoint material AND calls.
|
|
532
|
+
createWebRtcConnector({port, rtc, self, peer, pair, session?, label?, openTimeoutMs?}) -> RouteConnector
|
|
533
|
+
// the direct connector for createRouteCoordinator: open() drives offer/answer/ICE through the signal
|
|
534
|
+
// port, waits for the datachannel, returns a replay wire over it. RTCPeerConnection is NOT bundled:
|
|
535
|
+
// rtc is a runtime factory — browser `() => new RTCPeerConnection(cfg)`, Node werift/node-datachannel,
|
|
536
|
+
// tests an in-proc fake (RtcPeerConnection/RtcDataChannel are structural types, no lib.dom).
|
|
537
|
+
// revoke/close signals and channel death (incl. DURING open) fail loudly -> coordinator auto-fallback.
|
|
538
|
+
acceptWebRtcDirect({port, rtc, self, serve, accept?}) -> close()
|
|
539
|
+
// responder side: on offer, negotiates answer/ICE and serves serve(env) (exposeReplay(...) as is) into
|
|
540
|
+
// the incoming datachannel; accept(env) validates session material and rejects with a loud revoke
|
|
541
|
+
// (the initiator fails fast, not by timeout). Repeated offer for a pair recreates the session.
|
|
542
|
+
Peer.createPatchRelayJournal({history?, gap?: 'resume'|'sacred'}) -> {push(env) -> true|false|{seq}, remote, gap, seq(), snapshot(), close()}
|
|
543
|
+
// server-side mirror of an OWNER-sequenced patch line: push() takes the owner's envelopes VERBATIM.
|
|
544
|
+
// Owner seq space is the point: relay and direct routes share coordinates -> hand-off is a seq resume.
|
|
545
|
+
// CORRECTNESS CONTRACT (gap = the SERVER's data-type decision):
|
|
546
|
+
// 'resume' (default, folding): keyframe folded server-side (late joiners never need the owner online);
|
|
547
|
+
// a ROOT patch always resets the journal (owner restart AND keyframe repair share one rule); a
|
|
548
|
+
// non-root envelope with a seq gap — and a non-root FIRST envelope (partial-state lie) — is
|
|
549
|
+
// REJECTED as {seq: N}: that coordinate IS the repair request, no separate handshake exists.
|
|
550
|
+
// 'sacred': the journal never invents — no folded keyframe, no root-reset, strict contiguity only;
|
|
551
|
+
// frame() on an evicted tail THROWS. For data where an invented snapshot is unacceptable.
|
|
552
|
+
// Duplicates (reconnect/repair overlap) are idempotent no-ops. Rejection never corrupts the fold.
|
|
553
|
+
// remote is ReplayRemote-shaped (+ additive `seq()` for publisher resync) and rpc-exposable as is
|
|
554
|
+
// (line is a REAL Listen — the rpc layer detects listen nodes by registry, a hand-rolled
|
|
555
|
+
// {on: cb => ...} wrapper would not stream).
|
|
556
|
+
// Publisher side (createPeerClient): {journal?: 'resume'|'sacred', repair?: 'tail'|'keyframe',
|
|
557
|
+
// onPublishError?, resync()}. 'tail' = missed envelopes verbatim (falls back to a root keyframe if
|
|
558
|
+
// the local journal evicted them — resume only); 'keyframe' = one cheap root reset (ephemeral state).
|
|
559
|
+
// TYPE RULE, not a runtime check: journal:'sacred' narrows repair to 'tail' (tPublishRepair<J>) —
|
|
560
|
+
// lie about the journal kind and the relay simply keeps rejecting you (loud via onPublishError).
|
|
561
|
+
// resync() = call after transport reconnect: compares relay seq() with the local line, repairs the
|
|
562
|
+
// gap without waiting for the next write. Oracle: replay/peer-repair.test.ts (full gap matrix).
|
|
563
|
+
// The full SDK on top (createPeerHost/createPeerClient) is most-used surface -> wenay-common2.md.
|
|
564
|
+
// Host fragment also carries `presence` (>= 1.0.74): {list() -> string[], changes: Listen<{account,
|
|
565
|
+
// online}>} — refcounted per account (several connections = one identity), edges on 0<->1 only.
|
|
566
|
+
// changes is a PLAIN Listen (no replay journal): subscribe FIRST, then list(), to close the race.
|
|
567
|
+
// `peers` auto-creates an EMPTY relay journal on first touch (>= 1.0.74, Proxy over the dynamic
|
|
568
|
+
// keyspace): a mirror subscribed before its owner ever connected waits for the first publish
|
|
569
|
+
// instead of failing (no subscribe-order race). createPeerHost({accounts?: k => bool}) gates
|
|
570
|
+
// which keys may materialize — set it on public servers to stop junk-key journals.
|
|
571
|
+
Peer.createMediaRelay({lines: {name: 'video'|'audio'}, videoHistory? = 8, audioHistory? = 64, canWatch?}) -> {publishOf(account), watchOf(watcher), watch, lines(account), accounts(), dropAccount(account), close()}
|
|
572
|
+
// formalized demo media hub: per-account named replay lines, relay-first by design (media rides the
|
|
573
|
+
// socket relay; direct stays a separate opt-in). 'video' = keep-latest ring + current:'last' (late
|
|
574
|
+
// joiner pulls keyframe() instantly, frame() condenses to the last frame); 'audio' = short lossless
|
|
575
|
+
// queue. Frames are [frame, sentAt] — the wall-clock stamp feeds Media.attach* latency stats.
|
|
576
|
+
// publishOf is EAGER (the account's lines exist the moment the connection is wired) and re-resolves
|
|
577
|
+
// per frame — a dropAccount'ed account revives on its next publish.
|
|
578
|
+
// WATCH ACL: wire `media.watch = relay.watchOf(thisConnectionAccount)` — a per-watcher view whose
|
|
579
|
+
// Proxy has/get traps run canWatch(watcher, owner, line) on EVERY rpc path resolution (the dynamic
|
|
580
|
+
// walk does `seg in curr` + `curr[seg]` per call), so new subscribe/keyframe/frame are gated live —
|
|
581
|
+
// "media access follows the call" is app code: grant on 'active', revoke on 'ended'. Revocation
|
|
582
|
+
// also filters every forwarded frame/keyframe on an already-open policy view; the subscription
|
|
583
|
+
// stays allocated but receives no data. dropAccount(owner) closes its lines loudly.
|
|
584
|
+
// Views are cached per (watcher, owner) — stable node identity for the wire. An owner key is
|
|
585
|
+
// visible only if at least one line passes the policy (no keyspace metadata leak).
|
|
586
|
+
// `watch` (unfiltered global map) stays for trusted wiring (demo/single-tenant); prefer watchOf.
|
|
587
|
+
// dropAccount also clears the account's watcher-view cache both as owner and as watcher.
|
|
588
|
+
Peer.createCallManager({port, self, ringTimeoutMs? = 30000, incoming?}) -> {ready, call(peer, meta?), rings, active(), close()}
|
|
589
|
+
// messenger-style calls as envelopes over the EXISTING signal port (callPortOf(remote) flattens the
|
|
590
|
+
// fragment proxy) — zero new server surface; pair = 'call:<id>' never collides with route pairs.
|
|
591
|
+
// ready = registration ack (a self-probe rides the same ordered socket AFTER the subscribe — before
|
|
592
|
+
// it, a hub-emitted ring could be lost: plain Listen, no journal, by design). call() -> handle:
|
|
593
|
+
// {id, peer, direction, meta, state() 'ringing'|'active'|'ended', reason(), changed, ended: Promise,
|
|
594
|
+
// accept(), decline(reason?), hangup()}. Ends: 'declined'|'busy'|'offline' (send verdict false —
|
|
595
|
+
// fails FAST, no timeout)|'timeout' (both sides expire)|'hangup'|'canceled' (ringing side)|'closed'.
|
|
596
|
+
// incoming(info) gate: false = auto-decline 'busy'; the default gate declines during any live call,
|
|
597
|
+
// which also settles glare (both call each other -> mutual busy). Media is NOT owned by the call
|
|
598
|
+
// layer: on 'active' the app publishes/attaches relay lines itself (Media.attach* viewers).
|
|
599
|
+
// Oracle: replay/peer-call.test.ts (real Socket.IO/RPC wire, presence + calls + media relay).
|
|
600
|
+
serveReplayChannel(source, channel) <-> channelReplayRemote(channel) -> ReplayRemote
|
|
601
|
+
// replay wire over ANY ordered string channel (datachannel/MessagePort/worker/pipe): tiny JSON
|
|
602
|
+
// sub/req/res protocol, no RPC core — a direct channel lives OUTSIDE the main rpc connection.
|
|
603
|
+
// Channel close = non-envelope (null) on the line: replay subscribers report onError, never silence.
|
|
604
|
+
// ReplayMessageChannel = {send, onMessage, onClose?, close?}; channelFromDataChannel(dc) adapts a
|
|
605
|
+
// datachannel (and owns its handlers). Oracle: replay/route-webrtc.test.ts (fake RTC runtime +
|
|
606
|
+
// in-proc hub + the same signaling over a real Socket.IO/RPC wire).
|
|
607
|
+
exposeStoreReplay(store, opts?) <-> syncStoreReplay(mirror, remote, opts?) // layer B: patch line; keyframe = root patch ({path: [], value: snapshot})
|
|
608
|
+
syncStoreReplayRoute(mirror, remote, opts?) -> off & {ready, switch(nextRemote, opts), seq(), label(), active()} // same patch fold, but route-replaceable for relay/direct promotion
|
|
609
|
+
syncStoreReplayEach<T>(remote, cb, opts?) -> off & {store, ready, seq(), isStale(), lastTs()} // one-call per-key fold over the patch line (mirror + syncStoreReplay + store.each()); most-used surface — full contract + example in wenay-common2.md
|
|
610
|
+
createOfflineStore({key, remote?, initial, storage, version?, debounceMs?, syncOpts?}) -> Promise<OfflineStore<T>>
|
|
611
|
+
// snapshot-mode persisted mirror: read local {version,seq,snapshot,savedAt}, create a normal Store immediately,
|
|
612
|
+
// then syncStoreReplay(..., {since: savedSeq}) when remote exists. reconnect(remote) attaches later after offline start.
|
|
613
|
+
persistStore(store, {key, storage, seq?, debounceMs?}) -> control
|
|
614
|
+
// durable writes are snapshot+seq in one record; flush()/forceFlush(); statusListen emits ready/syncing/offline/stale/saving.
|
|
615
|
+
createMemoryOfflineStorage(initial?) -> OfflineStorage & {dump()}
|
|
616
|
+
// test/reference adapter. Browser IndexedDB/SQLite/file storage should implement the same OfflineStorage lambdas.
|
|
617
|
+
// Real wire oracle: `npx tsx replay/offline-store-socket.test.ts` (Socket.IO + RPC + persisted cache).
|
|
618
|
+
conflateReplay(replay, {pending, highWater, lowWater?, pollMs?, keyOf?, maxKeys?}) -> {api, close, stats} // layer D.1: per-connection gate — pending() over highWater -> deltas DROP (never queue);
|
|
619
|
+
// drained -> fresh keyframe on the SAME line, seq dedup cuts the overlap; pending() = e.g. socket.conn.writeBuffer.length
|
|
620
|
+
// build per connection where the rpc server is built; api spreads in place of exposeReplay(...); close() on disconnect
|
|
621
|
+
// one-call form: exposeReplay(replay, {conflate: opts}) -> {line, since, keyframe, close, stats} — same gate, wiring collapsed;
|
|
622
|
+
// destructure aside (const {close, stats, ...api} = ...) — close/stats must NOT reach the rpc object (they'd become remotely callable)
|
|
623
|
+
// keyOf (@deprecated — declare `frame` on the LINE instead; held-map path kept working for old calls):
|
|
624
|
+
// while lagged keep the LAST envelope per key, drain -> tail of those (ascending seq) instead of a full keyframe;
|
|
625
|
+
// events must be ABSOLUTE per key (store patches are — use storePatchKey from Observe); keyOf -> null or over maxKeys (1024) -> degrade to keyframe recovery
|
|
626
|
+
// exposeStoreReplay declares its condensing frame automatically (last patch per exact path) — zero config for stores
|
|
627
|
+
ReplayStorage = {putEvent, putKeyframe, getKeyframe({seq?|ts?}?), getEvents(from, to)} // layer C: archive behind 4 lambdas (file/DB/anything); createMemoryReplayStorage(caps?) = reference impl
|
|
628
|
+
archiveReplay(replay, {storage, everyEvents? = 64, everyMs?}) -> {close, stats} // event log + keyframe cadence (every N events OR T ms of line-ts, whichever first; frames only ON events)
|
|
629
|
+
openHistory(storage, live?) -> {at({seq?|ts?}?), subscribe(cb, {since?|ts?, onSeq?}) -> off} // seek + playback, SAME subscriber interface; with live: archive -> live journal -> live handover
|
|
630
|
+
// seamless rewind->live: create the line with getSince reading the same storage («memory outside»); else the gap closes with a keyframe jump (still consistent)
|
|
631
|
+
storeReplayAt(storage, {seq?|ts?}?) -> snapshot | undefined // store time machine: bit-exact state at any archived moment (same applyStorePatch mechanism)
|
|
632
|
+
```
|
|
633
|
+
> Killer property: a lagging/late/stalled consumer never gets a queue backlog — evicted seq / full outgoing buffer -> fresh keyframe + live from it.
|
|
634
|
+
> Files: `src/Common/events/replay-{listen,wire,conflate,history,index}.ts` + `src/Common/Observe/store-{replay,offline}.ts`;
|
|
635
|
+
> everything is additive (the canonical Listen surface gained only `registerListenOn`/`ListenOnBrand`; exposeStore/mirror untouched).
|
|
636
|
+
> Oracles: `npx ts-node replay/<f>.ts` — replay-listen / store-replay / offline-store / socket-replay / offline-store-socket / conflate / conflate-socket / coalesce / history / staleness / canvas-socket (raw bytes) / video-socket.demo;
|
|
637
|
+
> wire coverage also lives in the RPC harness cookbook (`npm run test:rpc`).
|
|
638
|
+
|
|
639
|
+
## 🔁 Observe — coarse reactive object (`Observe`, fact-based)
|
|
640
|
+
> `import { Observe } from "wenay-common2"` → `Observe.reactive(...)`.
|
|
641
|
+
> Coarse fact-based core: no public deltas, no string-path event API, no computed graph in core.
|
|
642
|
+
> Subscribe to the fact that a subtree changed, then re-read the current state.
|
|
643
|
+
```
|
|
644
|
+
const state = Observe.reactive({
|
|
645
|
+
account: {
|
|
646
|
+
balances: {BTC: 100, ETH: 400},
|
|
647
|
+
positions: {BTC: {qty: 0.5, entry: 60000}},
|
|
648
|
+
}
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
Observe.onUpdate(state.account, () => console.log("account changed"))
|
|
652
|
+
Observe.onUpdatePaths(state.account, ({paths}) => console.log(paths)) // optional dirty paths, relative to account
|
|
653
|
+
Observe.onUpdate(state.account.positions, () => console.log("positions changed"))
|
|
654
|
+
Observe.onUpdate(state.account.positions.BTC, () => console.log("BTC changed"))
|
|
655
|
+
|
|
656
|
+
state.account.positions = {BTC: {qty: 3, entry: 59000}, SOL: {qty: 10, entry: 130}}
|
|
657
|
+
await Observe.flushReactive(state)
|
|
658
|
+
```
|
|
659
|
+
```
|
|
660
|
+
reactive<T extends object>(obj, opts?) -> T
|
|
661
|
+
onUpdate(node, cb)->off
|
|
662
|
+
onUpdatePaths(node, cb)->off // cb({paths}); paths are relative to subscribed node
|
|
663
|
+
flushReactive(node)->Promise<void>
|
|
664
|
+
toRaw(node)->raw value // current raw target behind the proxy; creates no lazy nodes
|
|
665
|
+
listenUpdate(node)->Listen<void> // RPC bridge: createRpcServerAuto recognizes it
|
|
666
|
+
listenUpdatePaths(node)->Listen<{paths: PropertyKey[][]}>
|
|
667
|
+
|
|
668
|
+
opts: {
|
|
669
|
+
drain?: 'immediate' | 'micro' | number | ((flush)=>void)
|
|
670
|
+
depth?: number
|
|
671
|
+
eager?: boolean
|
|
672
|
+
}
|
|
673
|
+
```
|
|
674
|
+
RPC stacking:
|
|
675
|
+
```
|
|
676
|
+
const facade = {
|
|
677
|
+
getAccount: () => state.account,
|
|
678
|
+
accountChanged: Observe.listenUpdate(state.account),
|
|
679
|
+
accountChangedPaths: Observe.listenUpdatePaths(state.account),
|
|
680
|
+
btcChanged: Observe.listenUpdate(state.account.positions.BTC),
|
|
681
|
+
}
|
|
682
|
+
// createRpcServerAuto({ object: facade, ... }) exposes accountChanged/accountChangedPaths/btcChanged
|
|
683
|
+
// as normal RPC Listen subscriptions. This is a notification stream, not a full
|
|
684
|
+
// automatic snapshot mirror; send/read snapshots explicitly via facade methods.
|
|
8
685
|
```
|
|
9
|
-
new CObjectEventsArr<T>() / new CObjectEventsList<T>(log?=true) // handler collections
|
|
10
|
-
.add(item, {at?:'start'|'end'}) // item: { func?, func2?, del?, OnDel? }; default end (func2 = run-once-then-del)
|
|
11
|
-
.emit(data?) · .clear() · get size · .OnSpecEvent(fn)
|
|
12
|
-
// alias: Add/AddEnd->add · AddStart->add(_, {at:'start'}) · OnEvent->emit · Clean->clear · count/length->size
|
|
13
|
-
// CObjectEventsList warns at >20 subscriptions (leak detector)
|
|
14
|
-
|
|
15
|
-
Listen API from root import:
|
|
16
|
-
createListenCore<T>(opts?) -> core // minimal hot path: emit/on/off/once/close/count/keys
|
|
17
|
-
createListen<T>(producer, opts?) -> full // producer receives emit and may return teardown
|
|
18
|
-
createFastListen<T>(producer) -> full // createListen(..., {fast:true})
|
|
19
|
-
listen<T>(opts?) -> [emit, full] // pure event list: no value storage/current replay
|
|
20
|
-
slimListen<T>(opts?) -> [emit, slim] // slim view: on/off/close/count only
|
|
21
|
-
withStoreListen(full, currentGetter) -> storeListen
|
|
22
|
-
createStoreListen<T>(producer, {current,...opts}) -> storeListen
|
|
23
|
-
listenStore<T>({current,...opts}) -> [emit, storeListen]
|
|
24
|
-
// storeListen stores only the current getter reference, not store values
|
|
25
|
-
|
|
26
|
-
full Listen: .emit(...args) · .on(cb,{key?,cbClose?})->off · .off(key|cb) · .once(cb,{key?})->off · .onClose(cb)->off · .run() · .isRunning() · .close() · .count() · .keys()
|
|
27
|
-
store Listen: .on(cb,{current?:true|()=>args,key?,cbClose?})->off · .once(cb,{current?:true|()=>args,key?})->off
|
|
28
|
-
slim Listen: .on(cb,{key?})->off · .off(key|cb) · .close() · .count()
|
|
29
|
-
```
|
|
30
|
-
External current getter example:
|
|
31
|
-
```
|
|
32
|
-
const [emit, listen] = listenStore<[Market]>({
|
|
33
|
-
current: () => store.node.has() ? [store.node.snapshot()] : undefined,
|
|
34
|
-
})
|
|
35
|
-
listen.on(cb, {current: true}) // current store value first, then future emit(...) events
|
|
36
|
-
listen.once(cb, {current: true}) // current store value once, or waits for one future event if current() returns undefined
|
|
37
|
-
```
|
|
38
|
-
```
|
|
39
|
-
isListenCallback(obj) -> boolean // duck-type a full Listen result
|
|
40
|
-
socketBuffer(...) · listenSnapshot(...) // snapshot/buffer adapters over a SocketSource
|
|
41
|
-
```
|
|
42
|
-
## 🔢 number formatting & math (full)
|
|
43
|
-
```
|
|
44
|
-
round(value, digits=0) // alias: NormalizeDouble
|
|
45
|
-
roundSig(value, {digitsPoint?, digitsR?, type?: 'max'|'min'}) // alias: NormalizeDoubleAnd
|
|
46
|
-
formatAuto(value, maxDigits=8) -> string // alias: DblToStrAuto (negative maxDigits = significant digits)
|
|
47
|
-
formatSig(value, {digitsPoint?, digitsR?, type?}) -> string // alias: DblToStrAnd (pairs with roundSig)
|
|
48
|
-
decimals(value, maxDigits=8, minDigits=0) -> number // alias: GetDblPrecision / GetDblPrecision2
|
|
49
|
-
gcd(a, b, digits?=8) | gcd(values: Iterable<number>, digits?=8) // alias: MaxCommonDivisor / MaxCommonDivisorOnArray
|
|
50
|
-
CorrelationRollingByBuffer(data) // rolling Pearson correlation over a ring buffer
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
## 🗺️ niche data structures
|
|
54
|
-
```
|
|
55
|
-
class MyMap<K extends {valueOf():number}, V> // sorted keys
|
|
56
|
-
.set/.get/.has/.delete/.clear · get size · .clone() // JS-Map surface
|
|
57
|
-
// alias: Set/Get/Contains/Remove/Clear/Count->set/get/has/delete/clear/size · Clone->clone
|
|
58
|
-
// (no keys()/values()/entries() — use getters: sortedKeys: readonly K[], Values: readonly V[])
|
|
59
|
-
class MyNumMap<V> extends MyMap<number,V> // also indexable: map[5]=v / map[5]
|
|
60
|
-
class StructMap<TKey, TResult> { set/get/has/keys/values/entries } // tuple / multi-part keys
|
|
61
|
-
class StructSet<TKey> { add/has/keys/values }
|
|
62
|
-
class ArrayMap<TKey extends number|string, TVal> extends StructMap<readonly TKey[], TVal>
|
|
63
|
-
class ArraySet<TKey extends number|string> extends StructSet<readonly TKey[]>
|
|
64
|
-
new VirtualItems<T>(getItem:(i)=>T, getLength:()=>number) // lazy array via Proxy; indexable+iterable, .length
|
|
65
|
-
class CCachedValue2<TKey extends [any,any], TVal> { getOrSet(key, ()=>val) } // recompute when any key element changes
|
|
66
|
-
class CObjectID<TObject, TOwner> { value:number; static getInfo/getObjectByOwner } // opaque typed ids
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
## ⏳ cancellation & timers
|
|
70
|
-
```
|
|
71
|
-
class CancelablePromise<T> extends Promise<T> { constructor(exec, onCancel?); cancel(msg?); static resolve }
|
|
72
|
-
class CancelToken { get aborted; abort() } // poll-only (NOT a full AbortSignal). alias: isCancelled->aborted · cancel->abort
|
|
73
|
-
createCancellableTimer(interval_ms, onTimer:()=>boolean|void, onStop?) -> CancelablePromise<never> // onTimer()===false stops
|
|
74
|
-
createCancellableTaskWrapper<T>(task, isStopped, interval_ms=50)
|
|
75
|
-
class MyTimerInterval { constructor(period_ms, onTimer, onStop?); stop() }
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
## 🧭 object paths · linked list · byte stream
|
|
79
|
-
```
|
|
80
|
-
objectGet(obj, path:string[]) -> T // THROWS on missing/non-object segment (alias: objectGetValueByPath)
|
|
81
|
-
objectSet(obj, path, value) -> void (alias: objectSetValueByPath)
|
|
82
|
-
objectUnset(obj, path) -> boolean (alias: objectDeleteValueByPath)
|
|
83
|
-
deepEntries(obj, filter?) -> Generator<[key, value, path]> (alias: iterateDeepObjectEntries)
|
|
84
|
-
|
|
85
|
-
class CList<T> implements Iterable {
|
|
86
|
-
get first/last · get size (=length/count) · push(v)/unshift(v) -> node · pop()/shift() -> T|undefined
|
|
87
|
-
delete(valueOrNode)/deleteFirst()/deleteLast()/clear() · find(v)/findLast(v) · nodes()/reversedNodes()
|
|
88
|
-
} // immutable views: IList<T>, IListReadonly<T>, IListImmutable<T>
|
|
89
|
-
|
|
90
|
-
class ByteStreamW / ByteStreamR // pushNumber(value, type)/readNumber(type) over NumericTypes union
|
|
91
|
-
nullable(type: NumericTypes) // typed push*/read* (int8..uint64/float/double) stay as extended surface
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
## 🎀 decorators
|
|
95
|
-
```
|
|
96
|
-
wrap(fn, { beforeParams?, modifyParams?, afterParams?, onResult?, modifyResult?, onCatch?, onFinally? }) -> (...args)=>R
|
|
97
|
-
// hooks around a call (sync or async-aware). NOTE the error hook is `onCatch` (not onError). alias: enhancedDecorator
|
|
98
|
-
around(fn, ([args, fn]) => R) -> (...args) => R // AOP around-advice, fn passed UN-CALLED (lodash _.wrap). alias: Transformer
|
|
99
|
-
// also @deprecated -> wrap/around: enhancedTransformer, Decorator, TransformerResult
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
## ⏰ timeframes & periods
|
|
103
|
-
```
|
|
104
|
-
class TF { // S1, M1, H1, D1, ...
|
|
105
|
-
static get(name) -> TF|null · static getAsserted(name) -> TF · static fromSec(sec)
|
|
106
|
-
static createCustom(unit, count) · createCustomFromSec(sec) · readonly all · S1 S5 M1 M5 H1 D1 ...
|
|
107
|
-
get sec/msec/name // alias: fromName->get
|
|
108
|
-
}
|
|
109
|
-
class Period { get tf/index/startTime/endTime; static StartTimeForIndex(tf, index) }
|
|
110
|
-
class PeriodSpan · class CDelayer // deferred-run helper
|
|
111
|
-
durationToStr_h_mm_ss(ms) / _ms · durationToStrNullable(ms) // (alias: -> formatDuration)
|
|
112
|
-
toPrintObject(obj) // = convertDatesToStrings
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
## 🎨 color
|
|
116
|
-
```
|
|
117
|
-
rgb(r, g, b) -> ColorString
|
|
118
|
-
hue(value=180, count=100, index=1) -> ColorString // distinct palette color (alias: colorGeneratorByCount)
|
|
119
|
-
hueRGB(value=180, count=100, index=1) -> [r,g,b] (alias: colorGeneratorByCount2)
|
|
120
|
-
toRGBA(str: ColorString) -> [r,g,b,a] | toRGBA(str: string) -> [r,g,b,a]|undefined (alias: colorStringToRGBA)
|
|
121
|
-
toColorString(str) -> ColorString // validates, else throws
|
|
122
|
-
isSimilarColors(c1, c2, maxDelta=32) -> boolean
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
## 🖥️ console · proxy · id · rate-window · input
|
|
126
|
-
```
|
|
127
|
-
callerLine(lvl=0) -> "file:line:col func" // V8 caller frame (alias: __LineFile2; __LineFile/__LineFiles->callerLines)
|
|
128
|
-
callerLines(start=0, end=5) -> string[] // (alias: __LineFiles)
|
|
129
|
-
enable(flag=true) / disable() // clickable source links in console (IDE)
|
|
130
|
-
installProxyTracking() // call once at startup (browser fallback). isProxy(v)->boolean (alias: isProxyInit)
|
|
131
|
-
createIdPool() -> { next() -> number, release(id) } // reuses released ids
|
|
132
|
-
createRateWindow() -> { add(item), prune(type, ms?), sumWeight(type), readyAt(...), ...legacy } // alias: funcTimeW
|
|
133
|
-
rateWindow // shared default createRateWindow() instance (= FuncTimeWait)
|
|
134
|
-
SetAutoStepForElement(el, { minStep?, maxStep? }) // browser input
|
|
135
|
-
copyToClipboard(text) -> Promise<void> · GetEnumKeys(E) · isDate(v)
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
## ⚠️ errors
|
|
139
|
-
```
|
|
140
|
-
class MyError<D> extends Error { toJSON() -> tWire<D> } // wire-serializable error
|
|
141
|
-
toError = { ... } // build/normalize MyError from unknown
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
## 🌐 rpc (full)
|
|
145
|
-
```
|
|
146
|
-
// servers
|
|
147
|
-
createRpcServerAuto(opts) // canonical: nested object -> typed client proxy (auto Listen handling)
|
|
148
|
-
// replay-transparent exposure: facade members that are replay listens (replayListen — brand-detected,
|
|
149
|
-
// opts.replay: false|'auto'|'force') are exposed with BOTH surfaces under the SAME key: the legacy plain-Listen
|
|
150
|
-
// path stays byte-for-byte (Pkt.MAP only grows additively), plus line/frameLine/since/keyframe/frame — so
|
|
151
|
-
// upgrading listen -> replayListen is a declaration-site-only change; replaySubscribe(client.func.key) works as is.
|
|
152
|
-
// opts.replayOpts {pending?, highWater?, lowWater?, pollMs?} arms the per-connection lag gate on frameLine
|
|
153
|
-
// (consumer picks policy 'queue'|'frame' at subscribe time); the replay `line` is never throttled (no seq holes).
|
|
154
|
-
createRpcServer(opts) // lower-level core
|
|
155
|
-
createRpcServerAutoDetect(opts) // + legacy/v2 protocol auto-detection (createRpcServerAutoWithProtocolDetection)
|
|
156
|
-
createRpcServerInProc(...) // in-process fast path (no socket)
|
|
157
|
-
// clients
|
|
158
|
-
createRpcClientHub(opts) + rpc // multiplexing client hub: connect(token)/reauth(token)/onConnect
|
|
159
|
-
// alias: hub.setToken->connect
|
|
160
|
-
client members: func (proxy) · strict (schema-safe) · schema() · auth() · reauth() · onDisconnect()
|
|
161
|
-
close(reason?, {socketAlive?}) · ready() · init(obj?) · api.subscriptions()
|
|
162
|
-
// alias: dispose->close · readyStrict->ready · initStrict->init
|
|
163
|
-
noStrict(obj) / isNoStrict(obj) // dynamic (no-schema) subtree
|
|
164
|
-
endCallback(fn) // alias: rpcEndCallback
|
|
165
|
-
// subscription primitives (rare/manual; createRpcServerAuto/createRpcClientHub are the normal path)
|
|
166
|
-
listenSocket(parent, opts?) · listenSocketFirst · listenSocketAll · listenSocketSmart
|
|
167
|
-
deepListenFirst(obj, opts?) · deepListenAll · deepListenSmart
|
|
168
|
-
RPC Listen surface on client: stream.on(cb)->off · stream.once(cb)->off · stream.close()
|
|
169
|
-
// typed projection: client.func as unknown as DeepSocketListen<ServerFacade> (usually hidden behind a local webListen(client) helper).
|
|
170
|
-
// replay members project as ReplaySocketListen<Z> automatically (legacy surface + line/frameLine/since/keyframe/frame,
|
|
171
|
-
// tuples preserved end-to-end) — client.func.key passes to replaySubscribe as is, no casts.
|
|
172
|
-
// The same projection is built into BOTH typed-client paths (ClientAPIAll/ClientAPIStrict): on a plain rpc<T>() client
|
|
173
|
-
// replay members are already ReplaySocketListen on client.func/client.strict — no webListen and no casts for them
|
|
174
|
-
// (plain Listen members still need the DeepSocketListen projection).
|
|
175
|
-
// off is callable + thenable: off() unsubscribes; await off waits for stream end.
|
|
176
|
-
// *First/*All/*Smart differ only in callback arity: first arg / all args / single-vs-tuple smart.
|
|
177
|
-
matchKeys(a,b) · matchKeysList(a, keys) · deepMapByKeys · deepMapByKeysList
|
|
178
|
-
// wire serialization (rpc-walk): Date/Map/Set/RegExp/BigInt are marked+restored; functions -> callback refs.
|
|
179
|
-
// TypedArray/DataView/Buffer/ArrayBuffer pass through as BINARY leaves (socket.io carries them natively;
|
|
180
|
-
// never rebuilt into {0:…,1:…} dicts — raw canvas/video byte payloads are wire-safe and cheap).
|
|
181
|
-
RpcLimits (opt, per server/client): maxDepth 32 · maxKeys 1000 · maxArgs 64 · maxArrayLen 10k
|
|
182
|
-
· maxStringLen 1M · maxCallbacks 100 · maxPathLen 16 · maxBinaryLen 8MB (bytes per binary leaf)
|
|
183
|
-
// modes: func (proxy) · strict (schema-safe) · pipe (whole chain in one packet) · space (fire-and-forget)
|
|
184
|
-
// legacy (oldCommonsServer.ts, @deprecated forwarders onto oldСommonsServerMini — identical wire):
|
|
185
|
-
// funcPromiseServer->promiseServer · funcForWebSocket->wsWrapper · funcScreenerClient2->createClientProxy
|
|
186
|
-
// CreatAPIFacadeServerOld->createAPIFacadeServer ; CreatAPIFacadeClientOld & funcPromiseServer2 kept as-is
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
### RPC dynamic maps: prefer `noStrict` for personal/runtime keys
|
|
190
|
-
Use `noStrict(obj)` for user-scoped or runtime-keyed objects whose children are not a stable API schema: strategy maps, account maps, ORM/DB proxies, per-session private objects. The name is exactly `noStrict`.
|
|
191
|
-
|
|
192
|
-
```ts
|
|
193
|
-
return {
|
|
194
|
-
strategies: noStrict(strategyByName),
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
await client.func.strategies["mystrategy.2020"].start()
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
Contract:
|
|
201
|
-
- `noStrict` stops schema walking and routeMap indexing below that object.
|
|
202
|
-
- It is not an access-control boundary and does not bypass safe-key/path limits. Validate user-owned names in your facade if they are security-sensitive.
|
|
203
|
-
- RPC paths are arrays of string segments. `"mystrategy.2020"` is one segment, so the call above is `["strategies", "mystrategy.2020", "start"]`, not `["strategies", "mystrategy", "2020", "start"]`.
|
|
204
|
-
- The failure mode to avoid is treating `path.join(".")` as identity: `["a.b", "c"]` and `["a", "b", "c"]` both display as `a.b.c` but are different RPC paths.
|
|
205
|
-
- Static dotted keys are also supported: `api["a.b"].c` is distinct from `api.a.b.c`. Internal route/listen/cache identity must stay lossless; dotted strings are only a debug display form.
|
|
206
|
-
- If a branch is a fixed public API, keep it strict. If a branch is a personal/dynamic keyspace, wrap that branch in `noStrict` instead of trying to publish all current keys as schema.
|
|
207
|
-
|
|
208
|
-
## 🎙️ Media over socket — browser capture as binary Listen
|
|
209
|
-
> `import { Media } from "wenay-common2"` or `import * as Media from "wenay-common2/media"`.
|
|
210
|
-
> The hot path event is ONE `Uint8Array`: fixed 40-byte common2 media header + raw payload. No JSON envelope.
|
|
211
|
-
```
|
|
212
|
-
Media.createAudioSource(opts?) -> [emit, listen] & control
|
|
213
|
-
Media.createVideoSource(opts?) -> [emit, listen] & control
|
|
214
|
-
Media.encodeMediaFrame(meta, payload) -> Uint8Array
|
|
215
|
-
Media.decodeMediaFrame(frame) -> {kind, codec, seq, tMono, payload, sampleRate?, channels?, nSamples?, width?, height?}
|
|
216
|
-
|
|
217
|
-
control: start() -> Promise<MediaSourceState> · stop() · getStats() · setDevice(id) · listDevices()
|
|
218
|
-
state: 'idle'|'requesting'|'live'|'denied'|'no-device'|'error'
|
|
219
|
-
```
|
|
220
|
-
Audio source:
|
|
221
|
-
- default `mode:'pcm'`, `format:'int16'`, raw PCM payload; uses `AudioWorklet` when available and falls back to `ScriptProcessor` only when the browser cannot run a worklet.
|
|
222
|
-
- `mode:'record'` uses `MediaRecorder` chunks (`webm-opus`) for record/upload flows, not live STT.
|
|
223
|
-
- `getStats().rms` gives a VU-meter signal; permission denied/no device returns typed state, not a thrown public failure.
|
|
224
|
-
|
|
225
|
-
Video source:
|
|
226
|
-
- default snapshots, not a 30fps video stream: JPEG, `fps` default 3, `quality` default 0.82.
|
|
227
|
-
- each frame carries absolute image bytes, so `replay:true` can safely keep the latest frame for lag recovery.
|
|
228
|
-
- capture is hidden-tab-proof by default (Chrome throttles hidden tabs three ways, each stage has its own escape): the tick comes from a Blob-worker timer (in-page `setInterval` drops to ~1/s), the frame comes from `ImageCapture.grabFrame()` when available (a hidden `<video>` stops painting; `<video>->canvas` stays as the fallback), and JPEG encode runs in a worker over a transferred `ImageBitmap`, returning a transferred `ArrayBuffer` — never a structured-cloned frame (main-thread `convertToBlob` is gated to ~1s per call when hidden). `worker: false` opts out of all three into the plain in-page path.
|
|
229
|
-
- one explicit dimension (`width` or `height`) scales the other proportionally from the track resolution, downscale-only; pass both to force an exact size. `grabFrame`'s ~50ms serial latency caps the pipeline around ~15-20fps regardless of `fps`.
|
|
230
|
-
|
|
231
|
-
Viewer helpers (`media-view`): the consumer side of any media line (local pair or RPC surface).
|
|
232
|
-
- `attachVideoCanvas(line, canvas, {createBitmap?, onError?})` — per-frame codec/size come from the 40-byte header, canvas resizes to follow; decode overload is busy-skipped (keep-latest, `stats().frames` vs `stats().drawn` shows the gap); `createBitmap` injects a custom decoder (tests, OffscreenCanvas pipelines).
|
|
233
|
-
- `attachAudioPlayer(line, {maxBacklogSec? = 0.35, audioContext?, onError?})` — pcm16/float32 through a sequential playhead; a backlog past `maxBacklogSec` is dropped and the playhead rebases near "now" (live beats lossless; `stats().dropped` counts rebases). `enable()` must come from a user gesture (browser autoplay rules); `audioContext` injects a factory for tests/custom routing.
|
|
234
|
-
- `pipeMediaPublish(line, publish, {stamp? = true, onError?})` — fire-and-forget pipe into an RPC call; the default `Date.now()` stamp is what viewer `stats().ageMs` measures against. Both attach helpers also expose `stats().perSec` (rolling 1s rate).
|
|
235
|
-
- Oracle: `replay/media-view.test.ts`.
|
|
236
|
-
|
|
237
|
-
Replay/RPC wiring:
|
|
238
|
-
```ts
|
|
239
|
-
const audio = Media.createAudioSource({sourceId: 'mic'}) // plain lossless queue Listen
|
|
240
|
-
const video = Media.createVideoSource({sourceId: 'cam', fps: 2, replay: true})
|
|
241
|
-
|
|
242
|
-
createRpcServerAuto({
|
|
243
|
-
socket, socketKey: 'media',
|
|
244
|
-
object: {audio: audio[1], video: video[1]},
|
|
245
|
-
replayOpts: {highWater: 64, lowWater: 8},
|
|
246
|
-
})
|
|
247
|
-
```
|
|
248
|
-
`replay:true` makes the returned listen a `Replay.replayListen` surface before capture emits into it, so `createRpcServerAuto` brand-detects it and exposes legacy + replay under the same key. Defaults differ by media kind: audio replay is a sacred queue (`history:1024`, no keyframe/frame, do not drop samples); video replay is keep-latest (`history:256`, `current:'last'`, `frame` returns the newest covered frame). Pass `replay:{...}` for custom history/current/frame.
|
|
249
|
-
|
|
250
|
-
Backpressure rule: audio consumers should use the default queue policy unless the app explicitly accepts loss. Video consumers can use `Replay.replaySubscribe(remote.video, cb, {policy:'frame'})`; a slow socket drains to the latest frame instead of accumulating stale images. The binary frame itself is RPC-safe because `rpc-walk` passes `TypedArray`/`ArrayBuffer` leaves through natively and applies `maxBinaryLen`.
|
|
251
|
-
|
|
252
|
-
WebRTC future contract:
|
|
253
|
-
- `transport:'socket'` is the implemented default today.
|
|
254
|
-
- `transport:'webrtc'` is reserved and currently reports `state:'error'` on `start()`; it is not a hidden second transport.
|
|
255
|
-
- Future WebRTC support must be explicit opt-in for sub-200ms human duplex. Signaling belongs on the existing socket/RPC control channel (offer/answer/ICE), and backend/AI access requires an SFU that re-emits media bytes into the same `Media` Listen/replay surface. Downstream RPC/replay/store consumers must not change.
|
|
256
|
-
|
|
257
|
-
Oracle: `npx ts-node replay/media-socket.test.ts` checks header decode, plain Listen shape, `replay:true`, typed no-device state in Node, and real Socket.IO binary delivery.
|
|
258
|
-
|
|
259
|
-
## 📈 exchange — params (`CParams`)
|
|
260
|
-
```
|
|
261
|
-
class CParams / CParamsReadonly implements IParams
|
|
262
|
-
toValues(params) -> SimpleParams // IParams -> plain enabled values (alias: GetSimpleParams)
|
|
263
|
-
fromValues(infos, values) -> IParams // inverse (alias: mergeParamValuesToInfos)
|
|
264
|
-
isSimpleParams(params) -> boolean // (isSimpleParams2 @deprecated)
|
|
265
|
-
isParamBase(p) · isParamGroup(p) · isParamGroupOrArray(p)
|
|
266
|
-
enableAllParams(params, enabled=true) -> clone
|
|
267
|
-
// types: IParam (the union) + IParamBase are the entry points; the per-flavour IParamNum/IParamEnum/IParamTime*/...
|
|
268
|
-
// and *Readonly twins exist but read the union — wrap with ReadonlyFull<T> rather than the *Readonly aliases.
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
## 📈 exchange — bars (`Bars`)
|
|
272
|
-
```
|
|
273
|
-
class OHLC · class CBar extends CBarBase (IBar)
|
|
274
|
-
class CBars (IBarsImmutable) · class CBarsMutable / CBarsMutableExt (IBarsExt)
|
|
275
|
-
.push(bars|bar) // append (alias: Add)
|
|
276
|
-
.updateLast(bar) · .addTick(tick) · .addTicks(ticks) (alias: AddTick/AddTicks)
|
|
277
|
-
createRandomBars(tf, startTime, endTime|count, startPrice?, volatility?, tickSize?) -> CBars // alias: CreateRandomBars
|
|
278
|
-
class CTimeSeries<T=number> (ITimeseries) · CTimeSeriesReadonly<T>
|
|
279
|
-
findBarsShallow(srcBars, barsToFind) -> number
|
|
280
|
-
```
|
|
281
|
-
|
|
282
|
-
## 📈 exchange — market data (`MarketData`)
|
|
283
|
-
```
|
|
284
|
-
class CQuotesHistory
|
|
285
|
-
.get(tf) -> IBarsImmutable|null // build-on-demand (alias: Bars(tf))
|
|
286
|
-
class CQuotesHistoryMutable / CQuotesHistoryMutable2 extends CQuotesHistory
|
|
287
|
-
.append(bars[, tf]) (alias: AddEndBars) · .prepend(bars[, tf]) (alias: AddStartBars)
|
|
288
|
-
.addTicks(ticks) (alias: AddTicks; replaces last bar) · AddNewTicks (strict append-only, rare)
|
|
289
|
-
.deleteBefore(time)
|
|
290
|
-
```
|
|
291
|
-
|
|
292
|
-
## 🧩 server / socket helpers
|
|
293
|
-
```
|
|
294
|
-
SocketServerHook(opt?) · WebSocketServerHook(hook, params?, disconnect?) // server-side socket wiring
|
|
295
|
-
saveKeyValue({ dirDef, key? }) -> SaveKeyValueStore // fs-backed key/value store
|
|
296
|
-
createWebhookServer(params) · createWebhookClient(opts) · buildSelfWebhookUrl(ip, raw)
|
|
297
|
-
createSignatureFunction(hmacCreator) -> SignatureFunction
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
## 🧬 type utilities (`core/type.ts`, `core/BaseTypes.ts`)
|
|
301
|
-
```
|
|
302
|
-
Nullable<T> · PartialBy<T,K> · RequiredBy<T,K> · StringKeys<T> · ObjectEntries<T>
|
|
303
|
-
ArrayElementType<T> · TupleFirst<T>/TupleLast<T> · MapKeyType<T>/MapValueType<T> · ResolvedReturnType<T>
|
|
304
|
-
ReadonlyFull<T> · MutableFull<T> · Mutable<T> · Immutable<T> · const_Date
|
|
305
|
-
KeysByType<T,P> · PickTypes<T,P> · OmitTypes<T,E> · ReplaceKeyType<S,K,New>
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
## 🔁 Observe Store — path node facade + simple mirror sync
|
|
309
|
-
> Public v2 store API: `import { Observe } from "wenay-common2"` or `import * as Observe from "wenay-common2/observe"`.
|
|
310
|
-
> `Observe.createStore(initial)` wraps the fact-based `reactive` core with typed path nodes. `state` is the plain-feeling data object; `node` is the subscribable path tree. Transport stays simple: selected snapshots, not public diffs.
|
|
311
|
-
```
|
|
312
|
-
type Market = {data: {BTC?: number; ETH?: number}; meta: {status?: string}}
|
|
313
|
-
const store = Observe.createStore<Market>({data: {BTC: 1, ETH: 2}, meta: {status: 'ok'}})
|
|
314
|
-
|
|
315
|
-
store.state.data.BTC = 3 // local backend/frontend code writes normally
|
|
316
|
-
store.node.data.BTC.get() -> number | undefined
|
|
317
|
-
store.node.data.BTC.replace(4) // writes this path; set(v) = deprecated alias
|
|
318
|
-
store.node.data.BTC.on((v, ctx) => {}, {current: true}) // primitive leaf; ctx.path / ctx.pathString
|
|
319
|
-
store.node.data.BTC.once(cb, {current: true}) // current value counts as the event
|
|
320
|
-
store.node.data.on((data, ctx) => {}, {current: true, drain: 50})
|
|
321
|
-
store.on((whole) => {}, {current: true}) // whole store snapshot
|
|
322
|
-
store.count() // local subscribers through StoreNode
|
|
323
|
-
```
|
|
324
|
-
Typed masks / multiple subscriptions:
|
|
325
|
-
```
|
|
326
|
-
const sel = store.update({data: {BTC: true, ETH: true}, meta: {status: true}}, {current: true})
|
|
327
|
-
sel.get() -> {data: {BTC, ETH}, meta: {status}}
|
|
328
|
-
sel.on((snap, ctx) => {}) // aggregated selected snapshot; coalesced by default
|
|
329
|
-
sel.once((snap) => {}, {current: true})
|
|
330
|
-
sel.onEach((value, ctx) => { ctx.pathString }) // one event per SELECTED path, with route (explicit masks only)
|
|
331
|
-
```
|
|
332
|
-
`store.each()` — extended notes (the per-key feed itself: signature, expansion contract and the
|
|
333
|
-
canonical example live in wenay-common2.md):
|
|
334
|
-
- A key whose primitive value is unchanged by a root replace does not fire (the set trap skips
|
|
335
|
-
`Object.is`-equal writes); object values always fire — replay patches apply fresh snapshot copies.
|
|
336
|
-
- `each({depth})` is reserved: only `1` (top-level keys) is accepted today, anything else throws.
|
|
337
|
-
- `ctx` is `{path: [key]}`. `key` is typed `string`; symbol top-level keys pass through at runtime as-is.
|
|
338
|
-
- The `update(true).onEach` dev warn fires once per process (explicit key masks never warn —
|
|
339
|
-
`onEach` stays correct for them).
|
|
340
|
-
- `{'*': true}` is not a wildcard — it subscribes a literal `'*'` key (zero calls, no warn).
|
|
341
|
-
|
|
342
|
-
Backend expose + frontend mirror:
|
|
343
|
-
```
|
|
344
|
-
const facade = { market: Observe.exposeStore(store) }
|
|
345
|
-
// createRpcServerAuto({object: facade, ...}) exposes: get(mask?), changed/changedPaths Listen, set/replace(path,value)
|
|
346
|
-
|
|
347
|
-
const mirror = Observe.createStoreMirror<Market>(api.market, {data: {}, meta: {}})
|
|
348
|
-
const stopSync = await mirror.sync(
|
|
349
|
-
{data: {BTC: true, ETH: true}, meta: {status: true}},
|
|
350
|
-
{current: true, drain: 250}, // default partial:true uses changedPaths when available
|
|
351
|
-
)
|
|
352
|
-
mirror.node.data.BTC.on(v => render(v), {current: true})
|
|
353
|
-
stopSync()
|
|
354
|
-
```
|
|
355
|
-
Runnable example: `npx tsx observe/store-mirror.example.ts`.
|
|
356
|
-
Optional push-data channels (explicit high-frequency mode; usually choose one):
|
|
357
|
-
```
|
|
358
|
-
type StorePatch = {path: PropertyKey[]; value: any; exists: boolean}
|
|
359
|
-
type StoreChangedData = {mask: any; data: any}
|
|
360
|
-
|
|
361
|
-
const pushed = Observe.exposeStore(store, {push: true})
|
|
362
|
-
|
|
363
|
-
// Raw manual wiring: patch event carries one dirty path's current value.
|
|
364
|
-
pushed.patches!.on((patch) => {
|
|
365
|
-
Observe.applyStorePatch(mirror, patch) // exists:false means delete path
|
|
366
|
-
})
|
|
367
|
-
Observe.applyStorePatches(mirror, patches) // batch variant: apply an array of patches in order
|
|
368
|
-
|
|
369
|
-
// Batch-shaped dirty data: one event has dirty mask + snapshot for that mask.
|
|
370
|
-
pushed.changedData!.on(({mask, data}) => {
|
|
371
|
-
Observe.applyStoreMask(mirror, mask, data)
|
|
372
|
-
})
|
|
373
|
-
|
|
374
|
-
// Mirror helpers keep the client's selected mask and apply only its intersection
|
|
375
|
-
// with the global push event. current:true still does one initial get(mask).
|
|
376
|
-
const stopPatchSync = await mirror.syncPatches(
|
|
377
|
-
{data: {BTC: true}, meta: {status: true}},
|
|
378
|
-
{current: true, drain: 50},
|
|
379
|
-
)
|
|
380
|
-
const stopDataSync = await mirror.syncChangedData(
|
|
381
|
-
{data: {BTC: true}, meta: {status: true}},
|
|
382
|
-
{current: true, drain: 50},
|
|
383
|
-
)
|
|
384
|
-
```
|
|
385
|
-
|
|
386
|
-
Declarative manager over store resources:
|
|
387
|
-
```ts
|
|
388
|
-
const manager = Observe.createStoreManager({
|
|
389
|
-
market: Observe.managedStore.mirror({
|
|
390
|
-
remote: api.market,
|
|
391
|
-
initial: {data: {}, meta: {}},
|
|
392
|
-
mask: {data: {BTC: true}, meta: {status: true}},
|
|
393
|
-
tags: ['bootstrap', 'route:main'],
|
|
394
|
-
priority: 10,
|
|
395
|
-
sync: {mode: 'pull', opts: {current: true, drain: 'micro'}},
|
|
396
|
-
}),
|
|
397
|
-
rows: Observe.managedStore.offline({
|
|
398
|
-
remote: api.rows.replay,
|
|
399
|
-
initial: {},
|
|
400
|
-
storage: indexedDbStorage,
|
|
401
|
-
storageKey: 'rows',
|
|
402
|
-
tags: ['grid'],
|
|
403
|
-
priority: ({usage}) => usage?.weight ?? 0,
|
|
404
|
-
syncOpts: {staleMs: 30_000},
|
|
405
|
-
}),
|
|
406
|
-
video: Observe.managedStore.replay({
|
|
407
|
-
remote: api.video.replay,
|
|
408
|
-
initial: {},
|
|
409
|
-
explicitOnly: true,
|
|
410
|
-
large: true,
|
|
411
|
-
}),
|
|
412
|
-
})
|
|
413
|
-
|
|
414
|
-
manager.plan() // excludes explicitOnly/large by default
|
|
415
|
-
manager.plan({includeExplicit: true, includeLarge: true})
|
|
416
|
-
await manager.startPlanned({tags: ['bootstrap']})
|
|
417
|
-
manager.touch('rows', 3) // records local usage for future scoring
|
|
418
|
-
await manager.start('video', {explicit: true})
|
|
419
|
-
manager.stopAll()
|
|
420
|
-
```
|
|
421
|
-
|
|
422
|
-
Contract:
|
|
423
|
-
- `node` subscriptions are address-based, so `store.state.data = {BTC: 10}` keeps `store.node.data.BTC` subscriptions alive.
|
|
424
|
-
- Primitive, missing, and later-created paths are subscribable.
|
|
425
|
-
- `{current:true}` emits only when a value exists; absent paths wait for the first value.
|
|
426
|
-
- `drain` is per subscription/sync. Branch subscribers receive whole branch snapshots; mask `.on()` receives the selected snapshot; `.onEach()` receives `(value, ctx)` with route; `store.each()` receives `(key, value, ctx)` per changed top-level key.
|
|
427
|
-
- `pathString` is human-readable; internal route identity is collision-safe for dotted keys and distinct `Symbol()` keys.
|
|
428
|
-
- Mirror sync uses backend `changedPaths` when present: it pulls `selected mask ∩ dirty paths`; with no `changedPaths` or `{partial:false}` it falls back to `changed -> get(mask)`. UI subscribes to the local mirror store.
|
|
429
|
-
- Default `sync` is still pull-after-notify: event is light, reconnect is a fresh `get(mask)`, and each client owns its mask.
|
|
430
|
-
- `{push:true}` adds global push-data channels. `patches` emits `{path,value,exists}` per dirty path; `changedData` emits `{mask,data}` per dirty batch. They are separate from `changed`, so old clients and default mirror behavior do not change.
|
|
431
|
-
- `syncPatches` and `syncChangedData` are explicit mirror modes. They require the matching remote channel, do one initial `get(mask)` unless `{current:false}`, then apply pushed events without per-change round-trip.
|
|
432
|
-
- Push events are global, not per-subscriber mask streams. The mirror intersects each event with its own selected mask; a broad branch replace only updates selected leaves locally.
|
|
433
|
-
- Prefer default `sync` until round-trip cost or latency matters. Push mode sends more data in the event and reconnect should still resync with a fresh current snapshot.
|
|
434
|
-
- JSON/RPC transports should use JSON-safe path keys for push channels; `Symbol` paths are local-only even though the in-memory store can address them.
|
|
435
|
-
- Dirty paths are facts about changed object routes: add key, delete key, or deep set. Array mutation dirties the whole array branch; no public splice/index diff is promised.
|
|
436
|
-
- `snapshot()`/`update().get()` walk raw targets (`toRaw`), so a snapshot of a cold store creates no lazy reactive nodes.
|
|
437
|
-
- Writing a reactive proxy back into state stores its raw value (no reactive-in-reactive).
|
|
438
|
-
- Mirror `sync` pulls are chained sequentially: a slow (stale) response never overwrites a newer one.
|
|
439
|
-
- A slot keeps its proxy identity across an array↔object replace, so `Array.isArray` on a captured proxy reflects the original shape; JSON serialization follows the current value. Use `toRaw()` when the real shape matters.
|
|
440
|
-
|
|
441
|
-
Run coverage:
|
|
442
|
-
```bash
|
|
443
|
-
npx tsx observe/listen-store.test.ts
|
|
444
|
-
npx tsx observe/store.test.ts
|
|
445
|
-
npx tsx observe/store-manager.test.ts
|
|
446
|
-
npx tsx observe/store-mirror.example.ts
|
|
447
|
-
```
|
|
448
|
-
## 🎞️ Replay — snapshot + sequenced delta line
|
|
449
|
-
> Keyframe + seq-numbered deltas + recovery via a fresh keyframe — one pattern for store sync,
|
|
450
|
-
> ticks and video-like frame streams. `import { Replay } from "wenay-common2"` or
|
|
451
|
-
> `import { ... } from "wenay-common2/replay"`; the store pair lives in `Observe`.
|
|
452
|
-
> Design: `REPLAY-PLAN.md`; oracles: `replay/` (import the canonical `src/` modules).
|
|
453
|
-
```
|
|
454
|
-
withReplayListen(base, {current?, frame?, history?, getSince?, onJournal?, now?, staleMs?, onStale?}) · replayListen // layer A: journal {seq, ts, event}; on(cb, {since, onSeq}); head()/getSince()/keyframe()/hasKeyframe · isStale()/lastTs()
|
|
455
|
-
// FRAME MODEL — one method, two sources, three triggers. frame(sinceSeq, hint?) -> envelopes bringing a consumer
|
|
456
|
-
// from sinceSeq to head, as compact as the line allows; default = exact journal tail ?? keyframe.
|
|
457
|
-
// Source 1 (keyframe): `current` — full state SAMPLED from the owner of truth (never computed from deltas);
|
|
458
|
-
// sugar `current: 'last'` — single-entity lines: keyframe = last journaled envelope, no hand-kept state.
|
|
459
|
-
// Source 2 (mini-frame): `frame` lambda — gets the raw tail, returns a state-equivalent compact
|
|
460
|
-
// (last-per-entity, gap aggregate...); cost ~ touched entities, wins over keyframe while the journal covers.
|
|
461
|
-
// Line classes FOLLOW from declared lambdas (no mode flags): current+frame = condensable; current only =
|
|
462
|
-
// snapshot-recoverable; neither = sacred queue — full tail only, evicted -> frame() THROWS (loud, never silent loss).
|
|
463
|
-
// Triggers: reconnect (`since`), client pull (own timer — replaces any server-side interval mode), server gate drain.
|
|
464
|
-
// The transport sees ONLY seq; entity keys/skip rules live in producer lambdas (hint = opaque per-subscriber pass-through).
|
|
465
|
-
exposeReplay(replay) <-> replaySubscribe(remote, cb, {since?, onSeq?, staleMs?, onStale?, skewMs?, now?, policy?, hint?}) -> off // wire pair over the EXISTING rpc: line = plain Listen, since/keyframe/frame = plain methods
|
|
466
|
-
// NORMAL PATH: createRpcServerAuto exposes replay listens automatically (see rpc section) — exposeReplay stays
|
|
467
|
-
// as the manual/custom-transport path. replaySubscribe prefers `frame` when the server has it (one round trip,
|
|
468
|
-
// server picks tail/mini-frame/keyframe; sacred throw -> onError), falls back to since/keyframe on old servers.
|
|
469
|
-
// policy: 'queue' (default — socket buffers everything, nothing skipped) | 'frame' (subscribes frameLine when
|
|
470
|
-
// present: on lag the server drops and recovers via frame(lastSent) — mini-frame, no backlog). A non-envelope
|
|
471
|
-
// on the line (RPC_STOP — e.g. the gate's loud sacred failure) surfaces via onError + off, never silence.
|
|
472
|
-
// hint reaches the frame lambda on catch-up and on every explicit frame(seq, hint) call (pull); the push-gate's
|
|
473
|
-
// drain recovery uses the line's DEFAULT condensation — client-specific rules/pace = the pull path.
|
|
474
|
-
// off.ready (catch-up done) · off.seq() (reconnect point) · off.isStale()/off.lastTs(); reconnect = call again with {since: prev.seq()}
|
|
475
|
-
replayRouteSubscribe(remote, cb, {label?, since?, onSeq?, onError?, onRoute?}) -> off & {ready, switch(nextRemote, {label?, since?, reset?, policy?, hint?}), seq(), label(), active()}
|
|
476
|
-
// transport hand-off helper: old route remains live, replacement subscribes+catches up from seq, then old closes; overlap is seq-deduped. Use for relay -> direct and direct -> relay over any ordered ReplayRemote.
|
|
477
|
-
// DELIVERY CONTRACT (guaranteed, not best-effort): the subscriber's cb sees ONE uniform stream —
|
|
478
|
-
// first delivery = the snapshot (keyframe as an event of the SAME type; store: root patch),
|
|
479
|
-
// then only strictly-newer events, seq-ascending, no gaps, no dups. Live events racing ahead of the
|
|
480
|
-
// keyframe over the wire are queued during catch-up and seq-deduped — they can NEVER arrive first.
|
|
481
|
-
// With {since: K}: same fold, journal tail after K instead of a keyframe (evicted -> keyframe fallback,
|
|
482
|
-
// visible to the client as a seq jump > +1). Requires an ORDERED transport (socket.io / TCP / in-proc).
|
|
483
|
-
// Net effect: one client fold `state = apply(state, event)` handles cold start, reconnect,
|
|
484
|
-
// conflation recovery and archive playback identically — snapshot is not a special case.
|
|
485
|
-
// FRESHNESS (staleMs/onStale — an option, not consumer boilerplate): delivery is consistent but silent
|
|
486
|
-
// about staleness. Two failure modes it would otherwise hide: a SILENT LINE (producer died, line stays
|
|
487
|
-
// open, no envelopes) and a STALE KEYFRAME (arrives now, but its ts is old — "fresh over the wire" while
|
|
488
|
-
// minutes stale). onStale({stale, lastTs, age}) is edge-triggered BOTH ways, never a repeating alarm.
|
|
489
|
-
// Producer side: no journal event for staleMs -> stale; the timer exists only with onStale and arms after
|
|
490
|
-
// the first event (a cold line stays free); isStale()/lastTs() are lazy getters, no timer needed.
|
|
491
|
-
// Client side, two signals: ARRIVAL GAP (local clock, the only timer — catches the silent line regardless
|
|
492
|
-
// of clock skew) + ENVELOPE-TS AGE checked at delivery (producer clock — a stale keyframe reports stale
|
|
493
|
-
// IMMEDIATELY; clock-skew caveat: producer/client clocks may disagree, skewMs tolerance absorbs it, default 0).
|
|
494
|
-
// A since-tail's historical ts never flaps mid-catch-up (one assessment after handover); off() disarms the timer.
|
|
495
|
-
createRouteCoordinator({connect, policy?, shadow?, catchUpTimeoutMs?}) -> coordinator // policy-gated relay <-> direct routing shell over pure connectors (ROADMAP 0.1)
|
|
496
|
-
// LAYERING: a CONNECTOR is a pure transport (no route decisions) — {info: {label, kind: 'relay'|'direct',
|
|
497
|
-
// binary?, ordered?, reliable?}, open() -> ReplayRemote, close(), state(), metrics?() -> {rtt?, pending?},
|
|
498
|
-
// onFail?}; the COORDINATOR alone owns promotion/fallback (state machine + policy); data continuity is
|
|
499
|
-
// replayRouteSubscribe underneath, so ANY route change is gap-free and dup-free by the seq contract.
|
|
500
|
-
// WebRTC/NAT is deliberately absent here: a future datachannel is just another RouteConnector.
|
|
501
|
-
// connect(ref, kind) -> RouteConnector — transport factory per pair and kind (called per activation).
|
|
502
|
-
// policy hooks run BEFORE any transport action; absent hook = allowed, present hook must return true:
|
|
503
|
-
// canDirect (may the pair attempt direct) · mustRelay (force relay: NDA/audit/moderation — beats canDirect)
|
|
504
|
-
// · mustShadowRelay (direct payload + relay audit copy) · canExposeEndpoint (may signaling reveal
|
|
505
|
-
// endpoint/session material) · canReinterpose (may relay step back into the path).
|
|
506
|
-
// coordinator.pair(a, b) -> link (symmetric key: pair(a,b) == pair(b,a)) · state/promoteDirect/
|
|
507
|
-
// reinterposeRelay/fallback/block(pairOrKey, ...) · onRoute(cb) -> off (all transitions, all pairs)
|
|
508
|
-
// · pairs() · close()
|
|
509
|
-
// link: .subscribe(cb, opts?) -> off & {ready, seq(), label(), active()} // the pair's data: a replay stream
|
|
510
|
-
// that survives every route change; facade/authority semantics never learn the transport switched
|
|
511
|
-
// .promoteDirect({timeoutMs?, reason?}) -> Promise<{ok, state, reason?}> // policy denial and transport
|
|
512
|
-
// failure are EXPECTED OUTCOMES (result object), not exceptions; ops are serialized per link
|
|
513
|
-
// .reinterposeRelay(reason?) / .fallback(reason?) / .block(reason?) · .state() · .label() · .metrics() · .close()
|
|
514
|
-
// STATE MACHINE: relay -> direct:connecting -> direct | direct+shadowRelay; direct -> relay:reinterposing -> relay;
|
|
515
|
-
// failed/slow direct (timeoutMs) -> fallback (relay kept live the whole time, switch failed loudly);
|
|
516
|
-
// direct onFail (endpoint revoked, link died) -> auto fallback: close direct, resume relay from seq;
|
|
517
|
-
// any state -> blocked (terminal: subs closed, new subscribe throws, promote denied).
|
|
518
|
-
// direct+shadowRelay: payload rides direct while deps.shadow(ref, ...ev) receives the relay audit copy,
|
|
519
|
-
// starting from the consumers' seq coordinate — the switch window never escapes the audit.
|
|
520
|
-
// Acceptance oracle: replay/route-coordinator.test.ts (fake in-process relay/direct connectors).
|
|
521
|
-
createSignalHub({authorize?}) -> {register(account) -> {send, signals, close}, revoke(pair, accounts, reason?), accounts(), close()}
|
|
522
|
-
// WebRTC signaling over the EXISTING control channel: the port shape {send, signals} is a function +
|
|
523
|
-
// Listen — exactly what createRpcServerAuto exposes, so the relay socket IS the signaling wire
|
|
524
|
-
// (per connection: const port = hub.register(account); object = {send: port.send, signals: port.signals}).
|
|
525
|
-
// authorize(env) is the SERVER-side canExposeEndpoint point — endpoint/session material is revealed only
|
|
526
|
-
// past it; client-side coordinator policy stays advisory. from-spoofing is cut at the port.
|
|
527
|
-
// SignalEnvelope = {type: 'offer'|'answer'|'ice'|'revoke'|'close', pair, from, to, sdp?, candidate?,
|
|
528
|
-
// session?, reason?} — session is opaque auth material (the wire never looks inside).
|
|
529
|
-
createWebRtcConnector({port, rtc, self, peer, pair, session?, label?, openTimeoutMs?}) -> RouteConnector
|
|
530
|
-
// the direct connector for createRouteCoordinator: open() drives offer/answer/ICE through the signal
|
|
531
|
-
// port, waits for the datachannel, returns a replay wire over it. RTCPeerConnection is NOT bundled:
|
|
532
|
-
// rtc is a runtime factory — browser `() => new RTCPeerConnection(cfg)`, Node werift/node-datachannel,
|
|
533
|
-
// tests an in-proc fake (RtcPeerConnection/RtcDataChannel are structural types, no lib.dom).
|
|
534
|
-
// revoke/close signals and channel death (incl. DURING open) fail loudly -> coordinator auto-fallback.
|
|
535
|
-
acceptWebRtcDirect({port, rtc, self, serve, accept?}) -> close()
|
|
536
|
-
// responder side: on offer, negotiates answer/ICE and serves serve(env) (exposeReplay(...) as is) into
|
|
537
|
-
// the incoming datachannel; accept(env) validates session material and rejects with a loud revoke
|
|
538
|
-
// (the initiator fails fast, not by timeout). Repeated offer for a pair recreates the session.
|
|
539
|
-
Peer.createPatchRelayJournal({history?, gap?: 'resume'|'sacred'}) -> {push(env) -> true|false|{seq}, remote, gap, seq(), snapshot(), close()}
|
|
540
|
-
// server-side mirror of an OWNER-sequenced patch line: push() takes the owner's envelopes VERBATIM.
|
|
541
|
-
// Owner seq space is the point: relay and direct routes share coordinates -> hand-off is a seq resume.
|
|
542
|
-
// CORRECTNESS CONTRACT (gap = the SERVER's data-type decision):
|
|
543
|
-
// 'resume' (default, folding): keyframe folded server-side (late joiners never need the owner online);
|
|
544
|
-
// a ROOT patch always resets the journal (owner restart AND keyframe repair share one rule); a
|
|
545
|
-
// non-root envelope with a seq gap — and a non-root FIRST envelope (partial-state lie) — is
|
|
546
|
-
// REJECTED as {seq: N}: that coordinate IS the repair request, no separate handshake exists.
|
|
547
|
-
// 'sacred': the journal never invents — no folded keyframe, no root-reset, strict contiguity only;
|
|
548
|
-
// frame() on an evicted tail THROWS. For data where an invented snapshot is unacceptable.
|
|
549
|
-
// Duplicates (reconnect/repair overlap) are idempotent no-ops. Rejection never corrupts the fold.
|
|
550
|
-
// remote is ReplayRemote-shaped (+ additive `seq()` for publisher resync) and rpc-exposable as is
|
|
551
|
-
// (line is a REAL Listen — the rpc layer detects listen nodes by registry, a hand-rolled
|
|
552
|
-
// {on: cb => ...} wrapper would not stream).
|
|
553
|
-
// Publisher side (createPeerClient): {journal?: 'resume'|'sacred', repair?: 'tail'|'keyframe',
|
|
554
|
-
// onPublishError?, resync()}. 'tail' = missed envelopes verbatim (falls back to a root keyframe if
|
|
555
|
-
// the local journal evicted them — resume only); 'keyframe' = one cheap root reset (ephemeral state).
|
|
556
|
-
// TYPE RULE, not a runtime check: journal:'sacred' narrows repair to 'tail' (tPublishRepair<J>) —
|
|
557
|
-
// lie about the journal kind and the relay simply keeps rejecting you (loud via onPublishError).
|
|
558
|
-
// resync() = call after transport reconnect: compares relay seq() with the local line, repairs the
|
|
559
|
-
// gap without waiting for the next write. Oracle: replay/peer-repair.test.ts (full gap matrix).
|
|
560
|
-
// The full SDK on top (createPeerHost/createPeerClient) is most-used surface -> wenay-common2.md.
|
|
561
|
-
serveReplayChannel(source, channel) <-> channelReplayRemote(channel) -> ReplayRemote
|
|
562
|
-
// replay wire over ANY ordered string channel (datachannel/MessagePort/worker/pipe): tiny JSON
|
|
563
|
-
// sub/req/res protocol, no RPC core — a direct channel lives OUTSIDE the main rpc connection.
|
|
564
|
-
// Channel close = non-envelope (null) on the line: replay subscribers report onError, never silence.
|
|
565
|
-
// ReplayMessageChannel = {send, onMessage, onClose?, close?}; channelFromDataChannel(dc) adapts a
|
|
566
|
-
// datachannel (and owns its handlers). Oracle: replay/route-webrtc.test.ts (fake RTC runtime +
|
|
567
|
-
// in-proc hub + the same signaling over a real Socket.IO/RPC wire).
|
|
568
|
-
exposeStoreReplay(store, opts?) <-> syncStoreReplay(mirror, remote, opts?) // layer B: patch line; keyframe = root patch ({path: [], value: snapshot})
|
|
569
|
-
syncStoreReplayRoute(mirror, remote, opts?) -> off & {ready, switch(nextRemote, opts), seq(), label(), active()} // same patch fold, but route-replaceable for relay/direct promotion
|
|
570
|
-
syncStoreReplayEach<T>(remote, cb, opts?) -> off & {store, ready, seq(), isStale(), lastTs()} // one-call per-key fold over the patch line (mirror + syncStoreReplay + store.each()); most-used surface — full contract + example in wenay-common2.md
|
|
571
|
-
createOfflineStore({key, remote?, initial, storage, version?, debounceMs?, syncOpts?}) -> Promise<OfflineStore<T>>
|
|
572
|
-
// snapshot-mode persisted mirror: read local {version,seq,snapshot,savedAt}, create a normal Store immediately,
|
|
573
|
-
// then syncStoreReplay(..., {since: savedSeq}) when remote exists. reconnect(remote) attaches later after offline start.
|
|
574
|
-
persistStore(store, {key, storage, seq?, debounceMs?}) -> control
|
|
575
|
-
// durable writes are snapshot+seq in one record; flush()/forceFlush(); statusListen emits ready/syncing/offline/stale/saving.
|
|
576
|
-
createMemoryOfflineStorage(initial?) -> OfflineStorage & {dump()}
|
|
577
|
-
// test/reference adapter. Browser IndexedDB/SQLite/file storage should implement the same OfflineStorage lambdas.
|
|
578
|
-
// Real wire oracle: `npx tsx replay/offline-store-socket.test.ts` (Socket.IO + RPC + persisted cache).
|
|
579
|
-
conflateReplay(replay, {pending, highWater, lowWater?, pollMs?, keyOf?, maxKeys?}) -> {api, close, stats} // layer D.1: per-connection gate — pending() over highWater -> deltas DROP (never queue);
|
|
580
|
-
// drained -> fresh keyframe on the SAME line, seq dedup cuts the overlap; pending() = e.g. socket.conn.writeBuffer.length
|
|
581
|
-
// build per connection where the rpc server is built; api spreads in place of exposeReplay(...); close() on disconnect
|
|
582
|
-
// one-call form: exposeReplay(replay, {conflate: opts}) -> {line, since, keyframe, close, stats} — same gate, wiring collapsed;
|
|
583
|
-
// destructure aside (const {close, stats, ...api} = ...) — close/stats must NOT reach the rpc object (they'd become remotely callable)
|
|
584
|
-
// keyOf (@deprecated — declare `frame` on the LINE instead; held-map path kept working for old calls):
|
|
585
|
-
// while lagged keep the LAST envelope per key, drain -> tail of those (ascending seq) instead of a full keyframe;
|
|
586
|
-
// events must be ABSOLUTE per key (store patches are — use storePatchKey from Observe); keyOf -> null or over maxKeys (1024) -> degrade to keyframe recovery
|
|
587
|
-
// exposeStoreReplay declares its condensing frame automatically (last patch per exact path) — zero config for stores
|
|
588
|
-
ReplayStorage = {putEvent, putKeyframe, getKeyframe({seq?|ts?}?), getEvents(from, to)} // layer C: archive behind 4 lambdas (file/DB/anything); createMemoryReplayStorage(caps?) = reference impl
|
|
589
|
-
archiveReplay(replay, {storage, everyEvents? = 64, everyMs?}) -> {close, stats} // event log + keyframe cadence (every N events OR T ms of line-ts, whichever first; frames only ON events)
|
|
590
|
-
openHistory(storage, live?) -> {at({seq?|ts?}?), subscribe(cb, {since?|ts?, onSeq?}) -> off} // seek + playback, SAME subscriber interface; with live: archive -> live journal -> live handover
|
|
591
|
-
// seamless rewind->live: create the line with getSince reading the same storage («memory outside»); else the gap closes with a keyframe jump (still consistent)
|
|
592
|
-
storeReplayAt(storage, {seq?|ts?}?) -> snapshot | undefined // store time machine: bit-exact state at any archived moment (same applyStorePatch mechanism)
|
|
593
|
-
```
|
|
594
|
-
> Killer property: a lagging/late/stalled consumer never gets a queue backlog — evicted seq / full outgoing buffer -> fresh keyframe + live from it.
|
|
595
|
-
> Files: `src/Common/events/replay-{listen,wire,conflate,history,index}.ts` + `src/Common/Observe/store-{replay,offline}.ts`;
|
|
596
|
-
> everything is additive (the canonical Listen surface gained only `registerListenOn`/`ListenOnBrand`; exposeStore/mirror untouched).
|
|
597
|
-
> Oracles: `npx ts-node replay/<f>.ts` — replay-listen / store-replay / offline-store / socket-replay / offline-store-socket / conflate / conflate-socket / coalesce / history / staleness / canvas-socket (raw bytes) / video-socket.demo;
|
|
598
|
-
> wire coverage also lives in the RPC harness cookbook (`npm run test:rpc`).
|
|
599
|
-
|
|
600
|
-
## 🔁 Observe — coarse reactive object (`Observe`, fact-based)
|
|
601
|
-
> `import { Observe } from "wenay-common2"` → `Observe.reactive(...)`.
|
|
602
|
-
> Coarse fact-based core: no public deltas, no string-path event API, no computed graph in core.
|
|
603
|
-
> Subscribe to the fact that a subtree changed, then re-read the current state.
|
|
604
|
-
```
|
|
605
|
-
const state = Observe.reactive({
|
|
606
|
-
account: {
|
|
607
|
-
balances: {BTC: 100, ETH: 400},
|
|
608
|
-
positions: {BTC: {qty: 0.5, entry: 60000}},
|
|
609
|
-
}
|
|
610
|
-
})
|
|
611
|
-
|
|
612
|
-
Observe.onUpdate(state.account, () => console.log("account changed"))
|
|
613
|
-
Observe.onUpdatePaths(state.account, ({paths}) => console.log(paths)) // optional dirty paths, relative to account
|
|
614
|
-
Observe.onUpdate(state.account.positions, () => console.log("positions changed"))
|
|
615
|
-
Observe.onUpdate(state.account.positions.BTC, () => console.log("BTC changed"))
|
|
616
|
-
|
|
617
|
-
state.account.positions = {BTC: {qty: 3, entry: 59000}, SOL: {qty: 10, entry: 130}}
|
|
618
|
-
await Observe.flushReactive(state)
|
|
619
|
-
```
|
|
620
|
-
```
|
|
621
|
-
reactive<T extends object>(obj, opts?) -> T
|
|
622
|
-
onUpdate(node, cb)->off
|
|
623
|
-
onUpdatePaths(node, cb)->off // cb({paths}); paths are relative to subscribed node
|
|
624
|
-
flushReactive(node)->Promise<void>
|
|
625
|
-
toRaw(node)->raw value // current raw target behind the proxy; creates no lazy nodes
|
|
626
|
-
listenUpdate(node)->Listen<void> // RPC bridge: createRpcServerAuto recognizes it
|
|
627
|
-
listenUpdatePaths(node)->Listen<{paths: PropertyKey[][]}>
|
|
628
|
-
|
|
629
|
-
opts: {
|
|
630
|
-
drain?: 'immediate' | 'micro' | number | ((flush)=>void)
|
|
631
|
-
depth?: number
|
|
632
|
-
eager?: boolean
|
|
633
|
-
}
|
|
634
|
-
```
|
|
635
|
-
RPC stacking:
|
|
636
|
-
```
|
|
637
|
-
const facade = {
|
|
638
|
-
getAccount: () => state.account,
|
|
639
|
-
accountChanged: Observe.listenUpdate(state.account),
|
|
640
|
-
accountChangedPaths: Observe.listenUpdatePaths(state.account),
|
|
641
|
-
btcChanged: Observe.listenUpdate(state.account.positions.BTC),
|
|
642
|
-
}
|
|
643
|
-
// createRpcServerAuto({ object: facade, ... }) exposes accountChanged/accountChangedPaths/btcChanged
|
|
644
|
-
// as normal RPC Listen subscriptions. This is a notification stream, not a full
|
|
645
|
-
// automatic snapshot mirror; send/read snapshots explicitly via facade methods.
|
|
646
|
-
```
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|