wenay-common2 1.0.63 → 1.0.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +63 -0
- package/README.md +6 -322
- package/doc/NAMING_RENAMES.md +36 -0
- package/doc/RECOMMENDATIONS.md +56 -0
- package/doc/REPLAY-PLAN.md +81 -0
- package/doc/changes/1.0.63.md +8 -0
- package/doc/changes/1.0.64.md +10 -0
- package/doc/changes/README.md +8 -0
- package/doc/wenay-common2-rare.md +522 -0
- package/doc/wenay-common2.md +334 -0
- package/package.json +6 -2
- package/rpc.md +293 -0
|
@@ -0,0 +1,522 @@
|
|
|
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
|
+
## 📈 exchange — params (`CParams`)
|
|
209
|
+
```
|
|
210
|
+
class CParams / CParamsReadonly implements IParams
|
|
211
|
+
toValues(params) -> SimpleParams // IParams -> plain enabled values (alias: GetSimpleParams)
|
|
212
|
+
fromValues(infos, values) -> IParams // inverse (alias: mergeParamValuesToInfos)
|
|
213
|
+
isSimpleParams(params) -> boolean // (isSimpleParams2 @deprecated)
|
|
214
|
+
isParamBase(p) · isParamGroup(p) · isParamGroupOrArray(p)
|
|
215
|
+
enableAllParams(params, enabled=true) -> clone
|
|
216
|
+
// types: IParam (the union) + IParamBase are the entry points; the per-flavour IParamNum/IParamEnum/IParamTime*/...
|
|
217
|
+
// and *Readonly twins exist but read the union — wrap with ReadonlyFull<T> rather than the *Readonly aliases.
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## 📈 exchange — bars (`Bars`)
|
|
221
|
+
```
|
|
222
|
+
class OHLC · class CBar extends CBarBase (IBar)
|
|
223
|
+
class CBars (IBarsImmutable) · class CBarsMutable / CBarsMutableExt (IBarsExt)
|
|
224
|
+
.push(bars|bar) // append (alias: Add)
|
|
225
|
+
.updateLast(bar) · .addTick(tick) · .addTicks(ticks) (alias: AddTick/AddTicks)
|
|
226
|
+
createRandomBars(tf, startTime, endTime|count, startPrice?, volatility?, tickSize?) -> CBars // alias: CreateRandomBars
|
|
227
|
+
class CTimeSeries<T=number> (ITimeseries) · CTimeSeriesReadonly<T>
|
|
228
|
+
findBarsShallow(srcBars, barsToFind) -> number
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## 📈 exchange — market data (`MarketData`)
|
|
232
|
+
```
|
|
233
|
+
class CQuotesHistory
|
|
234
|
+
.get(tf) -> IBarsImmutable|null // build-on-demand (alias: Bars(tf))
|
|
235
|
+
class CQuotesHistoryMutable / CQuotesHistoryMutable2 extends CQuotesHistory
|
|
236
|
+
.append(bars[, tf]) (alias: AddEndBars) · .prepend(bars[, tf]) (alias: AddStartBars)
|
|
237
|
+
.addTicks(ticks) (alias: AddTicks; replaces last bar) · AddNewTicks (strict append-only, rare)
|
|
238
|
+
.deleteBefore(time)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## 🧩 server / socket helpers
|
|
242
|
+
```
|
|
243
|
+
SocketServerHook(opt?) · WebSocketServerHook(hook, params?, disconnect?) // server-side socket wiring
|
|
244
|
+
saveKeyValue({ dirDef, key? }) -> SaveKeyValueStore // fs-backed key/value store
|
|
245
|
+
createWebhookServer(params) · createWebhookClient(opts) · buildSelfWebhookUrl(ip, raw)
|
|
246
|
+
createSignatureFunction(hmacCreator) -> SignatureFunction
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## 🧬 type utilities (`core/type.ts`, `core/BaseTypes.ts`)
|
|
250
|
+
```
|
|
251
|
+
Nullable<T> · PartialBy<T,K> · RequiredBy<T,K> · StringKeys<T> · ObjectEntries<T>
|
|
252
|
+
ArrayElementType<T> · TupleFirst<T>/TupleLast<T> · MapKeyType<T>/MapValueType<T> · ResolvedReturnType<T>
|
|
253
|
+
ReadonlyFull<T> · MutableFull<T> · Mutable<T> · Immutable<T> · const_Date
|
|
254
|
+
KeysByType<T,P> · PickTypes<T,P> · OmitTypes<T,E> · ReplaceKeyType<S,K,New>
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## 🔁 Observe Store — path node facade + simple mirror sync
|
|
258
|
+
> Public v2 store API: `import { Observe } from "wenay-common2"` or `import * as Observe from "wenay-common2/observe"`.
|
|
259
|
+
> `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.
|
|
260
|
+
```
|
|
261
|
+
type Market = {data: {BTC?: number; ETH?: number}; meta: {status?: string}}
|
|
262
|
+
const store = Observe.createStore<Market>({data: {BTC: 1, ETH: 2}, meta: {status: 'ok'}})
|
|
263
|
+
|
|
264
|
+
store.state.data.BTC = 3 // local backend/frontend code writes normally
|
|
265
|
+
store.node.data.BTC.get() -> number | undefined
|
|
266
|
+
store.node.data.BTC.replace(4) // writes this path; set(v) = deprecated alias
|
|
267
|
+
store.node.data.BTC.on((v, ctx) => {}, {current: true}) // primitive leaf; ctx.path / ctx.pathString
|
|
268
|
+
store.node.data.BTC.once(cb, {current: true}) // current value counts as the event
|
|
269
|
+
store.node.data.on((data, ctx) => {}, {current: true, drain: 50})
|
|
270
|
+
store.on((whole) => {}, {current: true}) // whole store snapshot
|
|
271
|
+
store.count() // local subscribers through StoreNode
|
|
272
|
+
```
|
|
273
|
+
Typed masks / multiple subscriptions:
|
|
274
|
+
```
|
|
275
|
+
const sel = store.update({data: {BTC: true, ETH: true}, meta: {status: true}}, {current: true})
|
|
276
|
+
sel.get() -> {data: {BTC, ETH}, meta: {status}}
|
|
277
|
+
sel.on((snap, ctx) => {}) // aggregated selected snapshot; coalesced by default
|
|
278
|
+
sel.once((snap) => {}, {current: true})
|
|
279
|
+
sel.onEach((value, ctx) => { ctx.pathString }) // one event per SELECTED path, with route (explicit masks only)
|
|
280
|
+
```
|
|
281
|
+
`store.each()` — extended notes (the per-key feed itself: signature, expansion contract and the
|
|
282
|
+
canonical example live in wenay-common2.md):
|
|
283
|
+
- A key whose primitive value is unchanged by a root replace does not fire (the set trap skips
|
|
284
|
+
`Object.is`-equal writes); object values always fire — replay patches apply fresh snapshot copies.
|
|
285
|
+
- `each({depth})` is reserved: only `1` (top-level keys) is accepted today, anything else throws.
|
|
286
|
+
- `ctx` is `{path: [key]}`. `key` is typed `string`; symbol top-level keys pass through at runtime as-is.
|
|
287
|
+
- The `update(true).onEach` dev warn fires once per process (explicit key masks never warn —
|
|
288
|
+
`onEach` stays correct for them).
|
|
289
|
+
- `{'*': true}` is not a wildcard — it subscribes a literal `'*'` key (zero calls, no warn).
|
|
290
|
+
|
|
291
|
+
Backend expose + frontend mirror:
|
|
292
|
+
```
|
|
293
|
+
const facade = { market: Observe.exposeStore(store) }
|
|
294
|
+
// createRpcServerAuto({object: facade, ...}) exposes: get(mask?), changed/changedPaths Listen, set/replace(path,value)
|
|
295
|
+
|
|
296
|
+
const mirror = Observe.createStoreMirror<Market>(api.market, {data: {}, meta: {}})
|
|
297
|
+
const stopSync = await mirror.sync(
|
|
298
|
+
{data: {BTC: true, ETH: true}, meta: {status: true}},
|
|
299
|
+
{current: true, drain: 250}, // default partial:true uses changedPaths when available
|
|
300
|
+
)
|
|
301
|
+
mirror.node.data.BTC.on(v => render(v), {current: true})
|
|
302
|
+
stopSync()
|
|
303
|
+
```
|
|
304
|
+
Runnable example: `npx tsx observe/store-mirror.example.ts`.
|
|
305
|
+
Optional push-data channels (explicit high-frequency mode; usually choose one):
|
|
306
|
+
```
|
|
307
|
+
type StorePatch = {path: PropertyKey[]; value: any; exists: boolean}
|
|
308
|
+
type StoreChangedData = {mask: any; data: any}
|
|
309
|
+
|
|
310
|
+
const pushed = Observe.exposeStore(store, {push: true})
|
|
311
|
+
|
|
312
|
+
// Raw manual wiring: patch event carries one dirty path's current value.
|
|
313
|
+
pushed.patches!.on((patch) => {
|
|
314
|
+
Observe.applyStorePatch(mirror, patch) // exists:false means delete path
|
|
315
|
+
})
|
|
316
|
+
Observe.applyStorePatches(mirror, patches) // batch variant: apply an array of patches in order
|
|
317
|
+
|
|
318
|
+
// Batch-shaped dirty data: one event has dirty mask + snapshot for that mask.
|
|
319
|
+
pushed.changedData!.on(({mask, data}) => {
|
|
320
|
+
Observe.applyStoreMask(mirror, mask, data)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
// Mirror helpers keep the client's selected mask and apply only its intersection
|
|
324
|
+
// with the global push event. current:true still does one initial get(mask).
|
|
325
|
+
const stopPatchSync = await mirror.syncPatches(
|
|
326
|
+
{data: {BTC: true}, meta: {status: true}},
|
|
327
|
+
{current: true, drain: 50},
|
|
328
|
+
)
|
|
329
|
+
const stopDataSync = await mirror.syncChangedData(
|
|
330
|
+
{data: {BTC: true}, meta: {status: true}},
|
|
331
|
+
{current: true, drain: 50},
|
|
332
|
+
)
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Declarative manager over store resources:
|
|
336
|
+
```ts
|
|
337
|
+
const manager = Observe.createStoreManager({
|
|
338
|
+
market: Observe.managedStore.mirror({
|
|
339
|
+
remote: api.market,
|
|
340
|
+
initial: {data: {}, meta: {}},
|
|
341
|
+
mask: {data: {BTC: true}, meta: {status: true}},
|
|
342
|
+
tags: ['bootstrap', 'route:main'],
|
|
343
|
+
priority: 10,
|
|
344
|
+
sync: {mode: 'pull', opts: {current: true, drain: 'micro'}},
|
|
345
|
+
}),
|
|
346
|
+
rows: Observe.managedStore.offline({
|
|
347
|
+
remote: api.rows.replay,
|
|
348
|
+
initial: {},
|
|
349
|
+
storage: indexedDbStorage,
|
|
350
|
+
storageKey: 'rows',
|
|
351
|
+
tags: ['grid'],
|
|
352
|
+
priority: ({usage}) => usage?.weight ?? 0,
|
|
353
|
+
syncOpts: {staleMs: 30_000},
|
|
354
|
+
}),
|
|
355
|
+
video: Observe.managedStore.replay({
|
|
356
|
+
remote: api.video.replay,
|
|
357
|
+
initial: {},
|
|
358
|
+
explicitOnly: true,
|
|
359
|
+
large: true,
|
|
360
|
+
}),
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
manager.plan() // excludes explicitOnly/large by default
|
|
364
|
+
manager.plan({includeExplicit: true, includeLarge: true})
|
|
365
|
+
await manager.startPlanned({tags: ['bootstrap']})
|
|
366
|
+
manager.touch('rows', 3) // records local usage for future scoring
|
|
367
|
+
await manager.start('video', {explicit: true})
|
|
368
|
+
manager.stopAll()
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Contract:
|
|
372
|
+
- `node` subscriptions are address-based, so `store.state.data = {BTC: 10}` keeps `store.node.data.BTC` subscriptions alive.
|
|
373
|
+
- Primitive, missing, and later-created paths are subscribable.
|
|
374
|
+
- `{current:true}` emits only when a value exists; absent paths wait for the first value.
|
|
375
|
+
- `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.
|
|
376
|
+
- `pathString` is human-readable; internal route identity is collision-safe for dotted keys and distinct `Symbol()` keys.
|
|
377
|
+
- 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.
|
|
378
|
+
- Default `sync` is still pull-after-notify: event is light, reconnect is a fresh `get(mask)`, and each client owns its mask.
|
|
379
|
+
- `{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.
|
|
380
|
+
- `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.
|
|
381
|
+
- 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.
|
|
382
|
+
- 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.
|
|
383
|
+
- 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.
|
|
384
|
+
- 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.
|
|
385
|
+
- `snapshot()`/`update().get()` walk raw targets (`toRaw`), so a snapshot of a cold store creates no lazy reactive nodes.
|
|
386
|
+
- Writing a reactive proxy back into state stores its raw value (no reactive-in-reactive).
|
|
387
|
+
- Mirror `sync` pulls are chained sequentially: a slow (stale) response never overwrites a newer one.
|
|
388
|
+
- 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.
|
|
389
|
+
|
|
390
|
+
Run coverage:
|
|
391
|
+
```bash
|
|
392
|
+
npx tsx observe/listen-store.test.ts
|
|
393
|
+
npx tsx observe/store.test.ts
|
|
394
|
+
npx tsx observe/store-manager.test.ts
|
|
395
|
+
npx tsx observe/store-mirror.example.ts
|
|
396
|
+
```
|
|
397
|
+
## 🎞️ Replay — snapshot + sequenced delta line
|
|
398
|
+
> Keyframe + seq-numbered deltas + recovery via a fresh keyframe — one pattern for store sync,
|
|
399
|
+
> ticks and video-like frame streams. `import { Replay } from "wenay-common2"` or
|
|
400
|
+
> `import { ... } from "wenay-common2/replay"`; the store pair lives in `Observe`.
|
|
401
|
+
> Design: `REPLAY-PLAN.md`; oracles: `replay/` (import the canonical `src/` modules).
|
|
402
|
+
```
|
|
403
|
+
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()
|
|
404
|
+
// FRAME MODEL — one method, two sources, three triggers. frame(sinceSeq, hint?) -> envelopes bringing a consumer
|
|
405
|
+
// from sinceSeq to head, as compact as the line allows; default = exact journal tail ?? keyframe.
|
|
406
|
+
// Source 1 (keyframe): `current` — full state SAMPLED from the owner of truth (never computed from deltas);
|
|
407
|
+
// sugar `current: 'last'` — single-entity lines: keyframe = last journaled envelope, no hand-kept state.
|
|
408
|
+
// Source 2 (mini-frame): `frame` lambda — gets the raw tail, returns a state-equivalent compact
|
|
409
|
+
// (last-per-entity, gap aggregate...); cost ~ touched entities, wins over keyframe while the journal covers.
|
|
410
|
+
// Line classes FOLLOW from declared lambdas (no mode flags): current+frame = condensable; current only =
|
|
411
|
+
// snapshot-recoverable; neither = sacred queue — full tail only, evicted -> frame() THROWS (loud, never silent loss).
|
|
412
|
+
// Triggers: reconnect (`since`), client pull (own timer — replaces any server-side interval mode), server gate drain.
|
|
413
|
+
// The transport sees ONLY seq; entity keys/skip rules live in producer lambdas (hint = opaque per-subscriber pass-through).
|
|
414
|
+
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
|
|
415
|
+
// NORMAL PATH: createRpcServerAuto exposes replay listens automatically (see rpc section) — exposeReplay stays
|
|
416
|
+
// as the manual/custom-transport path. replaySubscribe prefers `frame` when the server has it (one round trip,
|
|
417
|
+
// server picks tail/mini-frame/keyframe; sacred throw -> onError), falls back to since/keyframe on old servers.
|
|
418
|
+
// policy: 'queue' (default — socket buffers everything, nothing skipped) | 'frame' (subscribes frameLine when
|
|
419
|
+
// present: on lag the server drops and recovers via frame(lastSent) — mini-frame, no backlog). A non-envelope
|
|
420
|
+
// on the line (RPC_STOP — e.g. the gate's loud sacred failure) surfaces via onError + off, never silence.
|
|
421
|
+
// hint reaches the frame lambda on catch-up and on every explicit frame(seq, hint) call (pull); the push-gate's
|
|
422
|
+
// drain recovery uses the line's DEFAULT condensation — client-specific rules/pace = the pull path.
|
|
423
|
+
// off.ready (catch-up done) · off.seq() (reconnect point) · off.isStale()/off.lastTs(); reconnect = call again with {since: prev.seq()}
|
|
424
|
+
// DELIVERY CONTRACT (guaranteed, not best-effort): the subscriber's cb sees ONE uniform stream —
|
|
425
|
+
// first delivery = the snapshot (keyframe as an event of the SAME type; store: root patch),
|
|
426
|
+
// then only strictly-newer events, seq-ascending, no gaps, no dups. Live events racing ahead of the
|
|
427
|
+
// keyframe over the wire are queued during catch-up and seq-deduped — they can NEVER arrive first.
|
|
428
|
+
// With {since: K}: same fold, journal tail after K instead of a keyframe (evicted -> keyframe fallback,
|
|
429
|
+
// visible to the client as a seq jump > +1). Requires an ORDERED transport (socket.io / TCP / in-proc).
|
|
430
|
+
// Net effect: one client fold `state = apply(state, event)` handles cold start, reconnect,
|
|
431
|
+
// conflation recovery and archive playback identically — snapshot is not a special case.
|
|
432
|
+
// FRESHNESS (staleMs/onStale — an option, not consumer boilerplate): delivery is consistent but silent
|
|
433
|
+
// about staleness. Two failure modes it would otherwise hide: a SILENT LINE (producer died, line stays
|
|
434
|
+
// open, no envelopes) and a STALE KEYFRAME (arrives now, but its ts is old — "fresh over the wire" while
|
|
435
|
+
// minutes stale). onStale({stale, lastTs, age}) is edge-triggered BOTH ways, never a repeating alarm.
|
|
436
|
+
// Producer side: no journal event for staleMs -> stale; the timer exists only with onStale and arms after
|
|
437
|
+
// the first event (a cold line stays free); isStale()/lastTs() are lazy getters, no timer needed.
|
|
438
|
+
// Client side, two signals: ARRIVAL GAP (local clock, the only timer — catches the silent line regardless
|
|
439
|
+
// of clock skew) + ENVELOPE-TS AGE checked at delivery (producer clock — a stale keyframe reports stale
|
|
440
|
+
// IMMEDIATELY; clock-skew caveat: producer/client clocks may disagree, skewMs tolerance absorbs it, default 0).
|
|
441
|
+
// A since-tail's historical ts never flaps mid-catch-up (one assessment after handover); off() disarms the timer.
|
|
442
|
+
exposeStoreReplay(store, opts?) <-> syncStoreReplay(mirror, remote, opts?) // layer B: patch line; keyframe = root patch ({path: [], value: snapshot})
|
|
443
|
+
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
|
|
444
|
+
createOfflineStore({key, remote?, initial, storage, version?, debounceMs?, syncOpts?}) -> Promise<OfflineStore<T>>
|
|
445
|
+
// snapshot-mode persisted mirror: read local {version,seq,snapshot,savedAt}, create a normal Store immediately,
|
|
446
|
+
// then syncStoreReplay(..., {since: savedSeq}) when remote exists. reconnect(remote) attaches later after offline start.
|
|
447
|
+
persistStore(store, {key, storage, seq?, debounceMs?}) -> control
|
|
448
|
+
// durable writes are snapshot+seq in one record; flush()/forceFlush(); statusListen emits ready/syncing/offline/stale/saving.
|
|
449
|
+
createMemoryOfflineStorage(initial?) -> OfflineStorage & {dump()}
|
|
450
|
+
// test/reference adapter. Browser IndexedDB/SQLite/file storage should implement the same OfflineStorage lambdas.
|
|
451
|
+
// Real wire oracle: `npx tsx replay/offline-store-socket.test.ts` (Socket.IO + RPC + persisted cache).
|
|
452
|
+
conflateReplay(replay, {pending, highWater, lowWater?, pollMs?, keyOf?, maxKeys?}) -> {api, close, stats} // layer D.1: per-connection gate — pending() over highWater -> deltas DROP (never queue);
|
|
453
|
+
// drained -> fresh keyframe on the SAME line, seq dedup cuts the overlap; pending() = e.g. socket.conn.writeBuffer.length
|
|
454
|
+
// build per connection where the rpc server is built; api spreads in place of exposeReplay(...); close() on disconnect
|
|
455
|
+
// one-call form: exposeReplay(replay, {conflate: opts}) -> {line, since, keyframe, close, stats} — same gate, wiring collapsed;
|
|
456
|
+
// destructure aside (const {close, stats, ...api} = ...) — close/stats must NOT reach the rpc object (they'd become remotely callable)
|
|
457
|
+
// keyOf (@deprecated — declare `frame` on the LINE instead; held-map path kept working for old calls):
|
|
458
|
+
// while lagged keep the LAST envelope per key, drain -> tail of those (ascending seq) instead of a full keyframe;
|
|
459
|
+
// events must be ABSOLUTE per key (store patches are — use storePatchKey from Observe); keyOf -> null or over maxKeys (1024) -> degrade to keyframe recovery
|
|
460
|
+
// exposeStoreReplay declares its condensing frame automatically (last patch per exact path) — zero config for stores
|
|
461
|
+
ReplayStorage = {putEvent, putKeyframe, getKeyframe({seq?|ts?}?), getEvents(from, to)} // layer C: archive behind 4 lambdas (file/DB/anything); createMemoryReplayStorage(caps?) = reference impl
|
|
462
|
+
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)
|
|
463
|
+
openHistory(storage, live?) -> {at({seq?|ts?}?), subscribe(cb, {since?|ts?, onSeq?}) -> off} // seek + playback, SAME subscriber interface; with live: archive -> live journal -> live handover
|
|
464
|
+
// seamless rewind->live: create the line with getSince reading the same storage («memory outside»); else the gap closes with a keyframe jump (still consistent)
|
|
465
|
+
storeReplayAt(storage, {seq?|ts?}?) -> snapshot | undefined // store time machine: bit-exact state at any archived moment (same applyStorePatch mechanism)
|
|
466
|
+
```
|
|
467
|
+
> Killer property: a lagging/late/stalled consumer never gets a queue backlog — evicted seq / full outgoing buffer -> fresh keyframe + live from it.
|
|
468
|
+
> Files: `src/Common/events/replay-{listen,wire,conflate,history,index}.ts` + `src/Common/Observe/store-{replay,offline}.ts`;
|
|
469
|
+
> everything is additive (the canonical Listen surface gained only `registerListenOn`/`ListenOnBrand`; exposeStore/mirror untouched).
|
|
470
|
+
> 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;
|
|
471
|
+
> wire coverage also lives in the RPC harness cookbook (`npm run test:rpc`).
|
|
472
|
+
|
|
473
|
+
## 🔁 Observe — coarse reactive object (`Observe`, fact-based)
|
|
474
|
+
> `import { Observe } from "wenay-common2"` → `Observe.reactive(...)`.
|
|
475
|
+
> Coarse fact-based core: no public deltas, no string-path event API, no computed graph in core.
|
|
476
|
+
> Subscribe to the fact that a subtree changed, then re-read the current state.
|
|
477
|
+
```
|
|
478
|
+
const state = Observe.reactive({
|
|
479
|
+
account: {
|
|
480
|
+
balances: {BTC: 100, ETH: 400},
|
|
481
|
+
positions: {BTC: {qty: 0.5, entry: 60000}},
|
|
482
|
+
}
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
Observe.onUpdate(state.account, () => console.log("account changed"))
|
|
486
|
+
Observe.onUpdatePaths(state.account, ({paths}) => console.log(paths)) // optional dirty paths, relative to account
|
|
487
|
+
Observe.onUpdate(state.account.positions, () => console.log("positions changed"))
|
|
488
|
+
Observe.onUpdate(state.account.positions.BTC, () => console.log("BTC changed"))
|
|
489
|
+
|
|
490
|
+
state.account.positions = {BTC: {qty: 3, entry: 59000}, SOL: {qty: 10, entry: 130}}
|
|
491
|
+
await Observe.flushReactive(state)
|
|
492
|
+
```
|
|
493
|
+
```
|
|
494
|
+
reactive<T extends object>(obj, opts?) -> T
|
|
495
|
+
onUpdate(node, cb)->off
|
|
496
|
+
onUpdatePaths(node, cb)->off // cb({paths}); paths are relative to subscribed node
|
|
497
|
+
flushReactive(node)->Promise<void>
|
|
498
|
+
toRaw(node)->raw value // current raw target behind the proxy; creates no lazy nodes
|
|
499
|
+
listenUpdate(node)->Listen<void> // RPC bridge: createRpcServerAuto recognizes it
|
|
500
|
+
listenUpdatePaths(node)->Listen<{paths: PropertyKey[][]}>
|
|
501
|
+
|
|
502
|
+
opts: {
|
|
503
|
+
drain?: 'immediate' | 'micro' | number | ((flush)=>void)
|
|
504
|
+
depth?: number
|
|
505
|
+
eager?: boolean
|
|
506
|
+
}
|
|
507
|
+
```
|
|
508
|
+
RPC stacking:
|
|
509
|
+
```
|
|
510
|
+
const facade = {
|
|
511
|
+
getAccount: () => state.account,
|
|
512
|
+
accountChanged: Observe.listenUpdate(state.account),
|
|
513
|
+
accountChangedPaths: Observe.listenUpdatePaths(state.account),
|
|
514
|
+
btcChanged: Observe.listenUpdate(state.account.positions.BTC),
|
|
515
|
+
}
|
|
516
|
+
// createRpcServerAuto({ object: facade, ... }) exposes accountChanged/accountChangedPaths/btcChanged
|
|
517
|
+
// as normal RPC Listen subscriptions. This is a notification stream, not a full
|
|
518
|
+
// automatic snapshot mirror; send/read snapshots explicitly via facade methods.
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
|