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.
@@ -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
-