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