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,334 @@
1
+ # wenay-common2 — BRIEF cheat sheet (notation)
2
+
3
+ > Root import: `import { ... } from "wenay-common2"`.
4
+ > Notation: `name(args: types) -> ret // note`. Types are shown where they decide a correct call (callback shape,
5
+ > overloads, return). Short names are **canonical**; removed old names are listed in `NAMING_RENAMES.md`.
6
+ > Full surface → **`wenay-common2-rare.md`**. Code style → `CLAUDE.md`. Full RPC guide → `rpc.md`.
7
+
8
+ ## ⭐ events — `listen` / `listenStore`
9
+ ```
10
+ import { listen, listenStore, mapListen } from "wenay-common2"
11
+
12
+ listen<T>(opts?) -> [emit, listen] // pure event list: no local value storage, no current replay
13
+ emit(...args: T) // dispatch event only
14
+ listen.on(cb: (...args: T) => void, {key?, cbClose?}) -> off
15
+ listen.once(cb, {key?}) -> off // one future event
16
+ listen.onClose(cb: () => void) -> off
17
+ listen.close() // clear listeners + fire close hooks + teardown producer
18
+ listen.count() -> number
19
+
20
+ listenStore<T>({current, ...opts}) -> [emit, listen]
21
+ // store wrapper: current() reads external store by reference; the listener does not keep its own value copy
22
+ listen.on(cb, {current: true}) -> off // current store value first, then future events
23
+ listen.on(cb) -> off // future events only
24
+ listen.once(cb, {current: true}) -> off // one value from current store, if present; otherwise waits future event
25
+ listen.once(cb) -> off // one future event
26
+ listen.on(cb, {current: () => argsOrUndefined}) -> off
27
+
28
+ opts: { fast? = true, event?(t: 'add'|'remove', count, api), closeOn? }
29
+ ```
30
+ ```
31
+ mapListen<TIn, TOut>(src, map: (...a: TIn) => TOut | null, opts?) -> [emit, listen] // map+filter (null skips); lazy subscribe
32
+ joinListens(listens | ports, keyExtractor?) -> { listen, add(port, key?), pending: number, clear(tid?) } // zip by key
33
+ ```
34
+ ## ⭐ sleep
35
+ ```
36
+ sleepAsync(ms = 0) -> Promise<void>
37
+ ```
38
+
39
+ ## ⏱️ async
40
+ ```
41
+ createThrottle() -> { throttle(ms: number, fn: () => void) -> void,
42
+ debounce(ms: number, fn: () => void) -> Promise<void> }
43
+ // fn is a ZERO-ARG thunk the scheduler runs itself; NOT a lodash-style wrapper that returns a callable
44
+ // ONE createThrottle() = ONE shared limiter (shared busy/pending) — use a SEPARATE instance per operation;
45
+ // throttle + debounce on the SAME instance contend (a busy throttle silently drops the debounce's trailing run)
46
+ createAsyncQueue(concurrency = 1) -> { add<R>(task: () => Promise<R>) -> Promise<R>, onIdle() -> Promise<void>, size: number } // p-queue
47
+ createReadyGate() -> { add(fn: () => void), ready() } // buffer fns until ready(), then run them in order
48
+ promiseProgress<T>(arr: (Promise<T> | (() => Promise<T>))[]) -> {
49
+ onOk(cb), onError(cb), all() -> Promise<any[]>, allSettled(), items(), stats() -> { ok: number, error: number, count: number } }
50
+ // factory entries start on .all()/.allSettled()/items() (once); .all() rejects like Promise.all — read aggregate progress via stats()
51
+ // alias: enhancedWaitRun->createThrottle · createTaskQueue->createReadyGate(.setReady->.ready) · createAsyncQueue.enqueue->add · .getQueueSize->size
52
+ ```
53
+
54
+ ## 🧰 core — clone / compare
55
+ ```
56
+ clone<T>(v: T) -> T // deep: cycles + Map/Set/Date, rebinds functions (alias: deepClone)
57
+ shallowClone<T>(v: T) -> T
58
+ isEqual(a, b) -> boolean // PLAIN object/array trees ONLY; on Map/Set/Date it returns a vacuous `true` — don't use it on them (alias: deepEqual)
59
+ shallowEqual(a, b) · arrayShallowEqual(a, b) -> boolean // strict-by-key vs loose-by-index — both kept on purpose
60
+ toImmutable<T>(o: T) -> T // deep-frozen clone + Mutable:false marker
61
+ JSON_clone<T>(o: T) -> T // JSON round-trip; DROPS Map/Set (-> {}) and turns Date -> string. NOT a rich clone — use clone() for those
62
+ ```
63
+
64
+ ## 🧰 core — binary search / maps / mutex / memo
65
+ ```
66
+ BSearch<T>(arr: ArrayLike<T>, value: T | comparer, match?: 'equal'|'lessOrEqual'|'greatOrEqual', sort?: SortMode) -> number
67
+ // comparer is (item: T) => number OR (a: T, b) => number; without it, T must have valueOf():number
68
+ BSearchNearest<T>(arr: ArrayLike<T>, value: number, getter?: (el: T) => number, maxDelta?: number) -> number
69
+ new MapExt<K,V>() / new WeakMapExt<K,V>(): .getOrSet(k, () => v) -> V // lazy insert (a plain value is also accepted)
70
+ new Mutex(): .runExclusive<T>(fn: () => T | Promise<T>) -> Promise<T> | .lock() -> Promise<release: () => void> // runExclusive: was dispatch
71
+ MemoFunc<A extends any[], R>(opts?: { timeDelta?: number, maxLimits?: number }) -> { func(...a: A) -> R, cleanAll(), memo } // TTL + LRU; per-call {timeDelta, reSave}
72
+ ```
73
+
74
+ ## 🔢 core — number (frequent)
75
+ ```
76
+ round(value: number, digits = 0) -> number // round to N decimals (alias: NormalizeDouble)
77
+ roundSig(value: number, { digitsR?: number /*total significant digits*/, digitsPoint? = 4, type?: 'max'|'min' }) -> number
78
+ // round to N significant digits — roundSig(1234.5678, {digitsR: 3}) -> 1230 (alias: NormalizeDoubleAnd)
79
+ gcd(a: number, b: number, digits = 8) -> number | gcd(values: Iterable<number>, digits = 8) -> number
80
+ // floats ok; in the ITERABLE overload the 2nd arg is PRECISION digits, not an operand (alias: MaxCommonDivisor[OnArray])
81
+ formatAuto(value: number, maxDigits = 8) -> string // shortest decimal string (alias: DblToStrAuto)
82
+ decimals(value: number, maxDigits = 8, minDigits = 0) -> number // count of meaningful decimals (alias: GetDblPrecision)
83
+ ```
84
+
85
+ ## ⏰ time
86
+ ```
87
+ format(date: Date, pattern, { utc? = true }) -> string // (alias of 11 timeToStr_*/timeLocalToStr_*)
88
+ pattern: 'HH:mm:ss' | 'HH:mm:ss.SSS' | 'yyyy-MM-dd'
89
+ | 'yyyy-MM-dd HH:mm' | 'yyyy-MM-dd HH:mm:ss' | 'yyyy-MM-dd HH:mm:ss.SSS'
90
+ | 'yyyy-MM-dd HH:mm O' | 'yyyy-MM-dd HH:mm:ss O' // O = GMT offset (local)
91
+ { utc: false } -> local variants
92
+ formatDuration(ms: number, pattern?: 'H:mm:ss' | 'H:mm:ss.SSS') -> string // clock; hours unbounded
93
+ durationToStr(ms: number) -> string // humanized
94
+ minDate(a: Date | null, b: Date | null) / maxDate(a, b) -> Date | null // null-tolerant (alias: MinTime/MaxTime)
95
+ convertDatesToStrings(obj) // Date -> strings, recursively (logs)
96
+ const: H1_S D1_S W1_S · M1_MS H1_MS D1_MS W1_MS
97
+ ```
98
+
99
+ ## 🌐 rpc (brief) — transport is ALWAYS caller-supplied (`{emit,on}`); there is NO url / built-in socket
100
+ ```
101
+ // SERVER: `object` is the impl tree, `socket` is a {emit,on} transport adapter
102
+ createRpcServerAuto({ socket: {emit, on}, object, socketKey: string, auth?, limits?, maxPerListen?, throttle?, opt?, replay?, replayOpts? }) -> { api, ... }
103
+ // replay: false|'auto' (default)|'force' — facade members that are replay lines (replayListen) are exposed
104
+ // with BOTH surfaces under the SAME key: legacy plain-Listen path byte-for-byte + line/frameLine/since/keyframe/frame.
105
+ // Upgrading listen -> replayListen is a declaration-site-only change; the facade and clients don't move.
106
+ // replayOpts: {pending?, highWater?, lowWater?, pollMs?} — per-connection lag gate for 'frame'-policy subscribers
107
+ // (pending defaults to socket.io writeBuffer; gates close on disconnect automatically). Replay lines are never throttled.
108
+ createRpcServer(opts) // lower-level core (same { socket, object, socketKey })
109
+ noStrict(obj) // mark a dynamic subtree (no schema)
110
+ endCallback(fn) // mark an RPC stream-callback's end (alias: rpcEndCallback)
111
+
112
+ // CLIENT hub: takes TWO functions — a socket factory + a schema builder; it is NOT an {url} or an options bag
113
+ createRpcClientHub(
114
+ createSocket: (token: string | null) => socket, // YOU build the socket, e.g. socket.io io(url, {auth:{token}})
115
+ schemaBuilder: (rpc) => ({ key: rpc<Api>('socketKey') }), // declare each socketKey's typed API
116
+ hubOpts?: { opt? },
117
+ ) -> hub
118
+ hub: connect(token) -> Promise<clients> · reauth(token) · facade · promise · socket · onConnect/onDisconnect // connect: was setToken
119
+ // connect()'s promise resolves on the socket's 'connect' event; for in-proc/loopback (no 'connect') use hub.facade + await hub.promise
120
+ client (on clients[key], NOT on the hub): func (proxy) · strict (schema-safe) · close() · ready() · init() · subscriptions()
121
+ // pipe = batch a server chain in one packet · space = fire-and-forget
122
+
123
+ // minimal wiring (the part no signature can show):
124
+ const [tick, ticks] = listen<[number]>()
125
+ createRpcServerAuto({ socket, object: { math: { add: (a, b) => a + b, ticks } }, socketKey: 'math' })
126
+ const hub = createRpcClientHub((t) => io(url, { auth: { t } }), (rpc) => ({ math: rpc<Api>('math') }))
127
+ const c = await hub.connect(token) // c = facade of per-socketKey clients
128
+ await c.math.ready(); await c.math.func.add(2, 3)
129
+ const l = c.math.func as unknown as DeepSocketListen<Api> // typed Listen projection; wrap as webListen(c.math) in app code
130
+ const off = l.ticks.on(v => console.log(v)) // canonical stream subscribe; off is callable and awaitable
131
+ off() // unsubscribe; .callback/.removeCallback are legacy compat, don't teach them
132
+ l.ticks.once(v => console.log(v)) // one event, then auto-off
133
+
134
+ // replay upgrade — ONE WORD at the declaration site, everything below follows automatically:
135
+ // const [tick, ticks] = listen<[number]>() // before
136
+ const [tick, ticks] = replayListen<[number]>({history: 1024, current: 'last'}) // after — same facade, same key
137
+ // legacy subscribers unchanged (byte-for-byte). Replay consumers now also get:
138
+ const sub = replaySubscribe(l.ticks, v => {}, {since: saved, onSeq: s => saved = s}) // catch-up + live, no gaps/dups
139
+ const sub2 = replaySubscribe(c.math.func.ticks, v => {}) // replay members project on func/strict directly — no cast needed
140
+ await l.ticks.frame(mySeq) // pull at YOUR pace (50ms timer etc.) — server condenses via the line's frame lambda
141
+ // full guide + examples → rpc.md; frame model / lag policies → 🎞️ recipe below and rare docs
142
+ ```
143
+
144
+ ## 🔁 Observe — reactive state + store/mirror API
145
+ > `import { Observe } from "wenay-common2"` or `import * as Observe from "wenay-common2/observe"`.
146
+ > This is the documented v2 reactive/store surface.
147
+ ```
148
+ // coarse reactive object: subscribe to the fact that a subtree changed, then re-read current state
149
+ Observe.reactive<T extends object>(obj, opts?) -> T
150
+ Observe.onUpdate(node, cb: () => void) -> off
151
+ Observe.onUpdatePaths(node, cb: ({paths}) => void) -> off // optional dirty paths, relative to node
152
+ Observe.flushReactive(node) -> Promise<void>
153
+ Observe.toRaw(node) -> raw value behind the proxy // snapshots/serialization without touching lazy nodes
154
+ Observe.listenUpdate(node) -> Listen<void> // RPC bridge for coarse change notifications
155
+ Observe.listenUpdatePaths(node) -> Listen<{paths: PropertyKey[][]}>
156
+ opts: { drain?: "immediate"|"micro"|number|((flush)=>void), depth?, eager? }
157
+
158
+ // path-addressed store facade over reactive()
159
+ Observe.createStore<T extends object>(initial, opts?) -> Store<T>
160
+ store.state // reactive data object; write normally
161
+ store.node.path.to.leaf.get()/snapshot()/replace(v) // set(v) is a deprecated alias of replace(v)
162
+ store.node.path.to.leaf.on((value, ctx) => {}, {current?, drain?, key?}) -> off
163
+ store.node.path.to.leaf.once(cb, opts?) -> off
164
+ store.update(mask, opts?) -> selection // typed selected snapshot
165
+ selection.get() · selection.on((snap, ctx)=>{}, opts?) -> off · selection.onEach((value, ctx)=>{}, opts?) -> off
166
+ store.each(opts?) -> Listen<[key, value, ctx]> // changed TOP-LEVEL keys as a plain Listen — THE per-key feed
167
+ // one call per CHANGED key per drain window: value = current store.state[key] at flush time; undefined = key deleted;
168
+ // two writes to one key in a window = ONE call (last value); deeper dirt (state.a.b = ...) reports 'a' once
169
+ // root replace (store.replace / mirror keyframe) EXPANDS: one call per key of the new state + (key, undefined)
170
+ // per key the replace removed — cold start / reconnect are NOT special cases for per-key consumers
171
+ // plain Listen shape (on(cb) -> off · once · count); zero cost while it has no subscribers
172
+ // NOT update(true).onEach: onEach fires per SELECTED path, and mask true selects the root —
173
+ // ONE call per window with the whole dict (a dev warn points to each())
174
+ store.count() -> number
175
+
176
+ // network shape: backend exposes snapshots + changed Listen; frontend mirrors selected masks locally
177
+ Observe.exposeStore(store, opts?) -> { get(mask?), set(path,value), replace(path,value), changed, changedPaths, patches?, changedData? }
178
+ Observe.createStoreMirror(remote, initial, opts?) -> store & { sync(mask, opts?) -> Promise<off>; syncPatches(mask, opts?) -> Promise<off>; syncChangedData(mask, opts?) -> Promise<off> }
179
+ // changedPaths is optional optimization: mirror pulls mask ∩ dirty paths; fallback is changed -> get(mask).
180
+ // Optional push-data mode: exposeStore(store,{push:true}) + syncPatches/syncChangedData; details in rare docs.
181
+
182
+ // Sequenced sync (replay line): seq-numbered patch stream — keyframe catch-up, reconnect by seq (tail, not snapshot)
183
+ Observe.exposeStoreReplay(store, {history? = 1024}) -> { api /* spread into the RPC server object */, replay, close }
184
+ // the patch line declares its condensing `frame` itself (last patch per exact path) — reconnect tails and
185
+ // lag recovery arrive as a mini-frame (changed paths only), zero config
186
+ Observe.syncStoreReplay(mirror, remote /*{line, since, keyframe, frame?} of api.replay*/, {since?, onSeq?}) -> off
187
+ // off.ready (catch-up done) · off.seq() (save for reconnect: syncStoreReplay(..., {since: prev.seq()}))
188
+ // lagging/late client NEVER gets a backlog: evicted seq -> ONE fresh keyframe + live
189
+ // freshness is an option, not consumer boilerplate: {staleMs, onStale} flags a silent line / stale keyframe (edge-triggered both ways; 🎞️ in rare docs)
190
+ Observe.syncStoreReplayEach<T>(remote, (key, value, ctx) => {}, opts?) -> off & {store, ready, seq(), isStale(), lastTs()}
191
+ // one-call remote fold: mirror store + syncStoreReplay + store.each() — the callback fires per CHANGED
192
+ // top-level key; first delivery = keyframe EXPANDED per key; (key, undefined) = key deleted
193
+ // opts = all replaySubscribe opts (since/onSeq/policy/staleMs/onStale/onError...) + {drain?, initial?}
194
+ // off() tears down BOTH the store sub and the wire sub; direct reads via off.store.state.KEY
195
+ // reconnect: syncStoreReplayEach(remote, cb, {since: prev.seq(), initial: prev.store.snapshot()})
196
+ // — the tail lands ON TOP of the previous state (a fresh empty mirror would not converge)
197
+ // Offline persisted mirror (snapshot mode): local cache first, then replay catch-up by seq
198
+ Observe.createOfflineStore({key, remote?, initial, storage, version?, debounceMs?, syncOpts?}) -> Promise<store & {ready, flush(), close(), status(), statusListen, reconnect(remote)}>
199
+ Observe.persistStore(store, {key, storage, seq?, debounceMs?}) -> {flush, forceFlush, close, setSeq, seq, status, statusListen}
200
+ Observe.createMemoryOfflineStorage(initial?) -> OfflineStorage
201
+ // persists {version, seq, snapshot, savedAt}; seq is the correctness coordinate, timestamps are UX/freshness only
202
+ // mode:'topLevel' is reserved; first implemented mode is snapshot
203
+
204
+ // Declarative resource manager above mirror/replay/offline: app chooses what to start, not the store core
205
+ Observe.managedStore.mirror({remote, initial, mask, tags?, priority?, explicitOnly?, large?, sync?})
206
+ Observe.managedStore.replay({remote, initial, tags?, priority?, explicitOnly?, large?, syncOpts?})
207
+ Observe.managedStore.offline({remote?, initial, storage, storageKey?, tags?, priority?, explicitOnly?, large?, syncOpts?})
208
+ Observe.createStoreManager(resources) -> {plan(opts?), start(key, opts?), startPlanned(opts?), stop(key), stopAll(), get(key), touch(key, weight?), usage(), statusListen, handles}
209
+ // plan excludes explicitOnly/large by default; {includeExplicit, includeLarge} opts opt them in
210
+ // Slow-client conflation: recipe section 🎞️ below. Full generic surface (any event line, history/time-travel) -> Replay namespace, 🎞️ in rare docs.
211
+ // Object add/delete/deep set are paths. Array mutation dirties the whole array branch, not splice internals.
212
+ ```
213
+ ```
214
+ type Market = {data: {BTC?: number; ETH?: number}; meta: {status?: string}}
215
+ const market = Observe.createStore<Market>({data: {BTC: 1, ETH: 2}, meta: {status: "ok"}})
216
+
217
+ market.state.data.BTC = 3 // plain local mutation
218
+ market.node.data.BTC.on((v, ctx) => {}, {current: true}) // ctx.path = ["data", "BTC"]
219
+ market.node.data.on(data => {}, {current: true, drain: 50}) // branch snapshot, per-sub drain
220
+ market.update({data: {BTC: true, ETH: true}}, {current: true}).on(snap => {})
221
+
222
+ // backend facade over RPC
223
+ const api = Observe.exposeStore(market)
224
+
225
+ // frontend mirror: UI subscribes to local mirror, not RPC directly
226
+ const mirror = Observe.createStoreMirror<Market>(api, {data: {}, meta: {}})
227
+ const stop = await mirror.sync({data: {BTC: true}, meta: {status: true}}, {current: true, drain: 250}) // uses changedPaths when available
228
+ mirror.node.data.BTC.on(v => {}, {current: true})
229
+ stop()
230
+
231
+ // per-key feed — dict store -> grid rows; keyframe / reconnect are just expansion, not special cases
232
+ type Rows = Record<string, {qty: number}>
233
+ const rows = Observe.createStore<Rows>({})
234
+ const offRows = rows.each().on((key, row) => { /* row === undefined ? removeRow(key) : upsertRow(key, row) */ })
235
+
236
+ // the same per-key contract over the wire — ONE call (mirror store + syncStoreReplay + each)
237
+ const exposed = Observe.exposeStoreReplay(rows, {history: 1024}) // server side: spread exposed.api into the RPC object
238
+ const feed = Observe.syncStoreReplayEach<Rows>(exposed.api.replay, (key, row) => {}, {drain: "micro"})
239
+ await feed.ready // catch-up done: keyframe arrived expanded per key
240
+ feed.store.state // the mirror — direct reads / extra subscriptions
241
+ feed() // tears down the store sub AND the wire sub
242
+ // reconnect later: syncStoreReplayEach(remote, cb, {since: feed.seq(), initial: feed.store.snapshot()})
243
+ // offline persisted mirror — cached snapshot first, then replay catch-up over the same remote
244
+ const offline = await Observe.createOfflineStore<Rows>({
245
+ key: "rows",
246
+ remote: exposed.api.replay,
247
+ initial: {},
248
+ storage: Observe.createMemoryOfflineStorage(), // use IndexedDB/SQLite adapter in an app
249
+ debounceMs: 250,
250
+ })
251
+ offline.each().on((key, row) => {})
252
+ await offline.ready
253
+ await offline.flush()
254
+ offline.close()
255
+
256
+ // configurable app-level resource plan
257
+ const manager = Observe.createStoreManager({
258
+ market: Observe.managedStore.mirror({remote: api.market, initial: {data: {}, meta: {}}, mask: {data: {BTC: true}}, tags: ['bootstrap'], priority: 10}),
259
+ rows: Observe.managedStore.offline({remote: exposed.api.replay, initial: {}, storage: Observe.createMemoryOfflineStorage(), tags: ['grid']}),
260
+ video: Observe.managedStore.replay({remote: videoReplay, initial: {}, explicitOnly: true, large: true}),
261
+ })
262
+ await manager.startPlanned({tags: ['bootstrap']})
263
+ manager.touch('rows', 3) // usage can raise future plan score
264
+ await manager.start('video', {explicit: true})
265
+ ```
266
+ Runnable example: `npx tsx observe/store-mirror.example.ts`.
267
+ Offline oracles: `npx tsx replay/offline-store.test.ts`; real Socket.IO/RPC wire: `npx tsx replay/offline-store-socket.test.ts`.
268
+
269
+ ## 🎞️ Fast ticks vs slow client — replay lines + server-owned lag gate (recipe)
270
+ > The problem: the producer emits faster than a bad link drains. Naive streaming grows an unbounded
271
+ > outgoing queue per slow client. The replay stack solves it with ONE mental model — the FRAME:
272
+ > `frame(sinceSeq, hint?)` on the line returns envelopes bringing a consumer from `sinceSeq` to now,
273
+ > as compact as the line allows (exact tail -> condensed mini-frame -> keyframe fallback). The same
274
+ > method serves reconnect (`since`), client pull (own pace) and lag recovery. The transport sees only
275
+ > `seq`; ALL event semantics live in two lambdas declared on the line: `current` (keyframe = pointer
276
+ > to truth) and `frame` (condenser — may honor a client-supplied `hint`, see below).
277
+ ```ts
278
+ import { Observe, Replay } from 'wenay-common2'
279
+
280
+ // ---- producer: declare what the line HAS (its class follows — no mode flags) ----
281
+ const [emitQuote, quotes] = Replay.replayListen<[string, number]>({
282
+ history: 4096,
283
+ frame: (tail, hint) => lastPerSymbol(tail, hint), // mini-frame; hint = client's pick of the condensation rule
284
+ }) // current+frame = condensable · current only = keyframe recovery · neither = sacred queue (never skipped, loud on eviction)
285
+ // store lines: exposeStoreReplay already declares current + frame (last patch per path) — zero config
286
+ const store = Observe.createStore<World>(initial, { drain: 'micro' })
287
+ const exposed = Observe.exposeStoreReplay(store, { history: 1024 })
288
+
289
+ // ---- per CONNECTION: the rpc server owns the gate; the facade does NOT change ----
290
+ io.on('connection', socket => {
291
+ const [disconnect, disconnectListen] = listen<[]>()
292
+ socket.on('disconnect', () => disconnect())
293
+ createRpcServerAuto({
294
+ socket: { emit: (k, d) => socket.emit(k, d), on: (k, cb) => socket.on(k, cb) },
295
+ socketKey: 'world',
296
+ object: { ...exposed.api, quotes }, // replay lines auto-exposed: both surfaces, same key
297
+ disconnectListen, // gates close on disconnect automatically
298
+ replayOpts: { highWater: 64, lowWater: 8 },// arms frameLine; pending defaults to socket.io writeBuffer
299
+ })
300
+ })
301
+
302
+ // ---- client: picks its LAG POLICY per subscription; no conflation logic anywhere ----
303
+ const sub = Replay.replaySubscribe(deep.quotes, cb, {since: saved, policy: 'frame'}) // server may skip; drain -> mini-frame
304
+ const sub2 = Replay.replaySubscribe(deep.quotes, cb2, {since: saved}) // 'queue' (default): nothing ever skipped
305
+ // own pace (e.g. 50ms skips + condensation): pull on YOUR timer — hint picks the rule, server condenses:
306
+ // every(50, async () => { for (const ev of await deep.quotes.frame(mySeq, hint)) apply(ev); })
307
+ // store mirror: Observe.syncStoreReplay(mirror, deep.replay, {since: prev.seq()}) — same contract
308
+ // delivery contract: FIRST delivery = snapshot/tail start (same event type), then strictly-newer,
309
+ // seq-ascending, deduped; reconnect via {since} = mini-frame/tail, not a full snapshot
310
+ ```
311
+ Rules that make it correct (violating any of these silently breaks convergence):
312
+ - **The line declares its recovery sources.** `current` (keyframe: SAMPLED from truth, never computed
313
+ from deltas; `'last'` = last envelope for single-entity lines) and/or `frame` (condenser). A line
314
+ with neither is a sacred queue: full tails only, evicted journal -> `frame()` THROWS (loud) — a
315
+ lagging `'frame'`-policy subscriber gets a stream end, never silent loss.
316
+ - **A `frame` result must be state-equivalent to the tail it replaces** (per the line's own semantics).
317
+ "Can't condense THIS tail" is legal and simple: return the tail as-is. Refuse-loudly is `throw`.
318
+ Multiple condensation standards live INSIDE the lambda, dispatched by the client-supplied `hint`
319
+ (opaque to the transport): `frame(tail, hint)`.
320
+ - **Events must be ABSOLUTE per their entity** for last-per-entity condensing (store patches are).
321
+ - Gate drops never hole the journal — it is written BEFORE any gate. Reconnect via `{since}` still
322
+ gets everything; only an evicted `history` window degrades to keyframe (visible as a seq jump).
323
+ - `pending()` and the watermarks share units (bytes, packets, frames — anything, but the same).
324
+
325
+ Manual path (pre-rev2, still works, `keyOf` @deprecated): build the gate yourself with
326
+ `Replay.conflateReplay(exposed.replay, {pending, highWater, keyOf})` and spread `gated.api` into the
327
+ facade — details in rare docs. New code should declare `frame` on the line instead.
328
+
329
+ Wire-level proof/oracles: `npx ts-node replay/rpc-auto.test.ts` (real Socket.IO: auto-exposure, legacy
330
+ parity, frame equivalence, gate lag sim), plus `replay/conflate-socket.test.ts`, `replay/conflate.test.ts`,
331
+ `replay/coalesce.test.ts`. Full generic surface (history/time-travel, archive) → 🎞️ in rare docs.
332
+
333
+
334
+
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "wenay-common2",
3
- "version": "1.0.63",
3
+ "version": "1.0.64",
4
4
  "description": "Common library",
5
5
  "strict": true,
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
8
8
  "files": [
9
9
  "lib/**/*",
10
+ "doc/**/*",
11
+ "CLAUDE.md",
12
+ "rpc.md",
13
+ "!doc/target/**/*",
10
14
  "!**/*.tsbuildinfo"
11
15
  ],
12
16
  "author": "wenay",
@@ -16,7 +20,7 @@
16
20
  },
17
21
  "repository": {
18
22
  "type": "git",
19
- "url": "https://github.com/wenayr/wenay-common2.git"
23
+ "url": "git+https://github.com/wenayr/wenay-common2.git"
20
24
  },
21
25
  "optionalDependencies": {
22
26
  "utf-8-validate": "^6.0.6"
package/rpc.md ADDED
@@ -0,0 +1,293 @@
1
+
2
+ Here is a comprehensive guide in a concise style. I've taken into account the architecture, backend, frontend (with the new `Hub` pattern), serialization nuances, limits, and hooks.
3
+
4
+ # wenay-common2 RPC: Complete Guide
5
+
6
+ Bidirectional, strongly-typed RPC protocol over sockets (Socket.IO or similar).
7
+ **Essence:** Server exposes a nested JS object $\to$ Client receives a typed proxy.
8
+
9
+ ---
10
+
11
+ ## 1. Architecture and Limitations
12
+
13
+ * **Multiplexing:** A single physical socket hosts independent channels (`socketKey`), each with its own API object.
14
+ * **Data Types:** Works with JSON-compatible data plus `Date`, `Map`, `Set`, `RegExp`, and `BigInt`. Class instances are sent as plain enumerable object data; methods/prototypes are not preserved.
15
+ * **Security (RpcLimits):** Server is protected from DDoS attacks. Strict limits on: `maxDepth`, `maxKeys`, `maxArrayLen`, `maxStringLen`, `maxCallbacks`. Exceeding throws `PayloadLimitError`.
16
+
17
+ ---
18
+
19
+ ## 2. Server (Backend)
20
+
21
+ ### 2.1 Socket Connection
22
+ ```typescript
23
+ import { createRpcServerAuto, listen } from "wenay-common2";
24
+
25
+ io.sockets.on('connection', (socket) => {
26
+ // 1. Create unsubscribe trigger for memory cleanup
27
+ const [stop, listenStop] = listen<[]>();
28
+ socket.on('disconnect', stop);
29
+
30
+ // 2. Initialize RPC channel on this socket
31
+ createRpcServerAuto({
32
+ socket,
33
+ socketKey: "mainAPI", // Channel identifier
34
+ object: buildFacade(client), // Target API object
35
+ disconnectListen: listenStop, // Auto-unsubscribe from Listen on disconnect
36
+ debug: process.env.DEV, // Packet logging
37
+ });
38
+ });
39
+ ```
40
+
41
+ ### 2.2 Building API Object (Facade)
42
+ The object is traversed by the server to build a "Schema" that is sent to the client.
43
+ ```typescript
44
+ import { noStrict, listen } from "wenay-common2";
45
+
46
+ // Create pub/sub event system
47
+ const [sendEvent, listenEvent] = listen<[string]>();
48
+
49
+ export function buildFacade(client) {
50
+ const role = (...roles) => hasRole(client, roles) ? true : null;
51
+
52
+ return {
53
+ // 1. Regular method
54
+ ping: () => "pong",
55
+
56
+ // 2. Nested namespaces + Role model
57
+ // If role() returns null, the method won't be sent to the client (returns null in schema)
58
+ admin: {
59
+ deleteUser: role("admin") && ((id) => db.delete(id)),
60
+ },
61
+
62
+ // 3. Dynamic objects (Proxy, ORM)
63
+ // Wrap in noStrict so the server doesn't try to read keys.
64
+ // Client will work with it in "blind" mode (without schema).
65
+ dbRef: noStrict(getProxyDb()),
66
+
67
+ // 4. Events
68
+ // Client will receive a Listen surface: .on(cb), .once(cb), .close()
69
+ events: { listenEvent },
70
+
71
+ // 5. Method with callback in arguments
72
+ // Callback lives ONLY while await is executing! After return, client deletes it.
73
+ stream: async (cb: (chunk: number) => void) => {
74
+ for(let i=0; i<10; i++) { cb(i); await sleep(50); }
75
+ return "done";
76
+ }
77
+ };
78
+ }
79
+ ```
80
+
81
+ ### 2.3 Server Hooks (Interceptors)
82
+ Use hooks to validate incoming packets.
83
+ ```typescript
84
+ createRpcServerAuto({
85
+ /*...*/
86
+ hooks: {
87
+ onRequest: async ({ key, request, fnName, fn }) => {
88
+ // Return false to block the call
89
+ return true;
90
+ },
91
+ onInvalid: ({ reason, key, request }) => {
92
+ console.warn(`RPC Attack/Error [${reason}]:`, key);
93
+ }
94
+ }
95
+ })
96
+ ```
97
+
98
+ ---
99
+
100
+ ## 3. Client (Frontend)
101
+
102
+ **Hub Pattern:** The frontend library doesn't depend on `socket.io-client`. Developer injects the socket factory into `createRpcClientHub`.
103
+
104
+ ### 3.1 Hub Initialization
105
+ ```typescript
106
+ import { io } from "socket.io-client";
107
+ import { createRpcClientHub, rpc } from "wenay-common2";
108
+ import type { MainFacade } from "../server/facade";
109
+
110
+ export const Api = createRpcClientHub(
111
+ // 1. Socket factory (DI)
112
+ (token) => io("http://localhost:4021", {
113
+ transports: ["websocket"],
114
+ query: token ? { token } : {}
115
+ }),
116
+
117
+ // 2. Channel registration
118
+ // rpc() accepts Facade type. Property name ("mainAPI") becomes socketKey.
119
+ (rpc) => ({
120
+ mainAPI: rpc<MainFacade>(),
121
+ })
122
+ );
123
+ ```
124
+
125
+ ### 3.2 Connection Lifecycle
126
+ ```typescript
127
+ // Listen to statuses
128
+ Api.onConnect((count) => console.log(`Socket connected (attempt ${count})`));
129
+
130
+ // Initiate connect. Creates socket, all channels automatically start.
131
+ await Api.connect("USER_SECRET_TOKEN");
132
+
133
+ // Disconnect
134
+ // await Api.connect(null);
135
+ ```
136
+
137
+ ### 3.3 Call Modes
138
+ Access API channel: `Api.facade.mainAPI`. Hub initializes all channels, so schema loads automatically.
139
+
140
+ ```typescript
141
+ const api = Api.facade.mainAPI;
142
+
143
+ // Wait for schema (REQUIRED before UI render)
144
+ await api.ready();
145
+
146
+ // --- 1. STRICT (Recommended) ---
147
+ // Safe call. Use `?.` since method may be `null` (closed by roles).
148
+ // If method is closed, returns `undefined` without sending network request.
149
+ const res = await api.strict.admin?.deleteUser?.(5);
150
+
151
+ // **TIP:** You can save .strict reference to avoid repetitive code:
152
+ // Works in ALL modes (strict, func, pipe, space, all)
153
+ const orchestrator = Api.facade.orchestrator?.strict;
154
+ await orchestrator?.repositories?.getAll?.();
155
+
156
+ // **IMPORTANT: Optional chaining for methods**
157
+ // - Use `?.` ONLY if method can be filtered by roles on backend
158
+ // - If backend has NO role filtering, method is ALWAYS available - skip `?.`
159
+ // Example: if backend has no roles, use .all mode instead:
160
+ const orchestrator = Api.facade.orchestrator?.all;
161
+ await orchestrator.repositories.getAll(); // No `?.` needed!
162
+ await orchestrator.deployments.getAll(); // Cleaner code!
163
+
164
+ // --- 2. FUNC (Standard) ---
165
+ const res2 = await api.func.ping();
166
+
167
+ // --- 3. PIPE (Pipeline) ---
168
+ // Entire chain goes to server in ONE network packet.
169
+ const data = await api.pipe.dbRef.users.find(1).getName();
170
+
171
+ // --- 4. SPACE (Fire-and-Forget) ---
172
+ // Doesn't wait for response, Promise resolves immediately.
173
+ api.space.admin.logAction("clicked");
174
+ ```
175
+
176
+ ### 3.4 Client Subscriptions (Listen)
177
+ `createRpcServerAuto` exposes server `listen` / `createListen` values as RPC Listen nodes. New code uses `on`/`once` and keeps the returned `off` handle. For TypeScript, project `client.func` to `DeepSocketListen<ServerFacade>`; this mirrors the runtime shape and keeps event argument types.
178
+ ```typescript
179
+ import type { DeepSocketListen } from "wenay-common2";
180
+
181
+ function webListen<T extends object>(client: { func: unknown }) {
182
+ return client.func as DeepSocketListen<T>;
183
+ }
184
+
185
+ const events = webListen<MainFacade>(api).events;
186
+
187
+ // Subscribe
188
+ const off = events.listenEvent.on((msg) => {
189
+ console.log("Push from server:", msg);
190
+ });
191
+
192
+ // Unsubscribe. The handle is callable and also thenable.
193
+ off();
194
+ // await off; // waits until the stream ends
195
+
196
+ // One event, then automatic unsubscribe
197
+ const done = events.listenEvent.once((msg) => {
198
+ console.log("First push:", msg);
199
+ });
200
+ await done;
201
+ ```
202
+
203
+ Compatibility names `.callback(cb)`, `.removeCallback()`, and `.unsubscribe()` still exist for old clients, but they are not the recommended API.
204
+
205
+
206
+ ### 3.5 Request Management and Debug
207
+ Each facade has a system object `api` for low-level control, as well as a couple of methods in the root:
208
+
209
+ ```typescript
210
+ const { api, abortAll, schema } = Api.facade.mainAPI;
211
+
212
+ // --- 1. Monitoring and Debug ---
213
+ api.pending(); // Current number of pending responses (Promises)
214
+ api.callbacks(); // Current number of live callback ids in memory
215
+ api.log(true); // Enable logging of all incoming/outgoing packets to console
216
+
217
+ // --- 2. Targeted cleanup (inside .api) ---
218
+ api.clearPromises(true); // Reject (cancel) all current requests
219
+ api.clearCallbacks(); // Force clear all callbacks
220
+ api.remove(myFunc); // Force-release a specific callback id (alias: .end)
221
+
222
+ // --- 3. Global facade methods ---
223
+ abortAll("User logout"); // Hard reset: reject all promises with RPC_ABORT error + clear all callback ids
224
+ const map = schema(); // Get raw schema tree (MAP) sent by server
225
+ ```
226
+
227
+
228
+ ---
229
+
230
+ ## 4. Advanced Features
231
+
232
+ ### 4.1 Listen Argument Interception Modes
233
+ When using events, the client auto-handler (`mode: "smart"`) by default flexibly adapts arguments. If server sends one argument — it comes as a value, if multiple — as an array.
234
+ If you create a client manually without Hub, you can set a strict mode:
235
+ ```typescript
236
+ // "first" — listener always receives only the first argument
237
+ // "all" — listener always receives all arguments
238
+ // "smart" — (default) auto-detection
239
+ const autoApi = createRpcClientAuto(api.func, { mode: "first" });
240
+ ```
241
+
242
+
243
+ ### 4.2 Manual Callback Termination from Server
244
+ This is a low-level escape hatch for callbacks passed as ordinary function arguments. For Listen subscriptions prefer `off()`/`.once()`.
245
+ ```typescript
246
+ import { endCallback } from "wenay-common2";
247
+
248
+ async function myMethod(cb: (data: any) => void) {
249
+ cb("chunk 1");
250
+ endCallback(cb); // Alias: rpcEndCallback. Sends "___STOP" to client.
251
+ // Client callback id is deleted, subsequent cb() calls won't go anywhere.
252
+ }
253
+ ```
254
+
255
+ ### 4.3 Call / Apply Support on Client
256
+ The client proxy can transparently handle standard JS `call` and `apply` calls. Server correctly normalizes them ("collapses" the path):
257
+ ```typescript
258
+ // Both variants correctly call `api.users.create("Ivan")` on server
259
+ await api.func.users.create.call(null, "Ivan");
260
+ await api.func.users.create.apply(null, ["Ivan"]);
261
+ ```
262
+
263
+
264
+ ### 4.4 Transparent PIPE Request Transit
265
+ For microservice architecture. If your Node server itself is a client of another RPC node (via `pipe`), it can "pass through" the remainder of the pipe chain further using `__executeRemainingPipe`, without waiting for an intermediate response.
266
+ This covers the important RPC mechanics (Listen `on/once/off`, `endCallback`, `call/apply`, `pipe-transit`) while maintaining readability.
267
+ ```typescript
268
+
269
+ /**
270
+ * Extract orchestrator type from Api facade
271
+ */
272
+ export type OrchestratorFacade = NonNullable<typeof Api.facade.orchestrator>['strict'];
273
+
274
+ /**
275
+ * Hook for RPC initialization and facade access
276
+ */
277
+ export function useOrchestrator() {
278
+ const [rpcInitialized, setRpcInitialized] = useState(false);
279
+
280
+ useEffect(() => {
281
+ Api.connect(null); // Connect without token
282
+ Api.facade.orchestrator?.ready().then(() => {
283
+ setRpcInitialized(true);
284
+ });
285
+ }, []);
286
+
287
+ // Use .all mode for simpler code without optional chaining when no role filtering
288
+ const orchestrator = Api.facade.orchestrator?.all;
289
+
290
+ return { orchestrator, rpcInitialized };
291
+ }
292
+
293
+ ```