@zakkster/lite-pick 0.4.0 → 0.5.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ All notable changes to `@zakkster/lite-pick` are documented here. The format fol
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.5.0] - 2026-09-23
8
+
9
+ M5: the ergonomic request layer at the `@zakkster/lite-pick/pool` subpath -- dispatch/settle
10
+ in-flight counters + distinct-endpoint failover + a duck-typed query-cache fetcher (ROADMAP.md M5).
11
+
12
+ ### Added
13
+
14
+ - `@zakkster/lite-pick/pool` (`Pool.js`) -- a new SUBPATH export (the kernel `Pick.js` stays a
15
+ single 0 B/op file; the async layer lives outside it, the lite-query `/stream` + `/await`
16
+ precedent, ADR 0007).
17
+ - `Pool` -- wraps a balancer + the caller-owned in-flight view. `run(fn, opts?)` picks an endpoint,
18
+ increments in-flight on dispatch, awaits `fn(endpoint, signal)`, decrements on settle (in a
19
+ `finally` -- net-zero per run, even on throw). On a thrown error it keeps the failed endpoint's
20
+ count ELEVATED and re-picks, so a load-aware strategy (P2C/LeastConn/SED/NQ) steers the next
21
+ attempt to a DISTINCT endpoint -- up to `opts.tries` attempts (default 1 = no failover), then
22
+ rejects with the last error. Rejects a `code:'LITE_PICK_NONE'` error when no endpoint is eligible;
23
+ `opts.signal` is passed to `fn` and, once aborted after a failure, stops failover. NOT a 0 B/op
24
+ path (the kernel `pick()` is) -- a normal async wrapper, disclosed.
25
+ - `liteQueryFetcher(pool, perEndpoint, opts?)` -- returns a `({ key, signal }) => Promise` fetcher
26
+ for a query cache (lite-query's `fetcher`, or any fetcher-shaped consumer). Imports NOTHING from
27
+ lite-query -- duck-typed, so `peerDependencies` stays empty. `opts.tries` is the spatial failover
28
+ count. BOUNDARY: Pool owns SPATIAL failover across the pool; the cache owns TEMPORAL retry/backoff.
29
+ - `test/Pool.test.js` -- 12 tests: dispatch/settle in-flight balance (success AND throw), fail-closed
30
+ coding, distinct-endpoint failover, tries exhaustion (last error), abort-stops-failover, signal
31
+ passthrough, a 200-way CONCURRENT-consistency check (in-flight drains to all-zero -- no leak), and
32
+ the duck-typed fetcher.
33
+ - `Pool.d.ts` + `test/types/pool.test-d.ts` -- the typed surface (the type-test tsconfig gains the
34
+ `DOM` lib for `AbortSignal`).
35
+ - `demo/fanout.mjs` (`npm run demo`) -- the integration moat: least-connections fan-out over a flaky
36
+ pool with a replica killed mid-run, proving 0 dead picks + 0 leaked in-flight + live failover, and
37
+ showing the lite-query fetcher wiring. (`demo/` is not in `files[]`.)
38
+ - `decisions/0007-pool-adapter.md` -- the /pool-subpath home, spatial-vs-temporal retry ownership,
39
+ the explicit 0 B/op boundary, and the duck-typed (zero-peer) fetcher.
40
+
41
+ ### Changed
42
+
43
+ - Version 0.4.0 -> 0.5.0 across `package.json`, `Pick.js` `VERSION` (re-exported by `Pool.js`), and
44
+ `llms.txt`. `exports` gains `./pool`; `files[]` gains `Pool.js` + `Pool.d.ts`.
45
+ - `peerDependencies` stays `{}` -- the fetcher adapter is duck-typed (ADR 0007 Fork 4).
46
+
7
47
  ## [0.4.0] - 2026-09-23
8
48
 
9
49
  M4: the exact LeastConn family (IPVS `lc` / `sed` / `nq` made zero-GC) + the seeded invariant
package/Pick.js CHANGED
@@ -37,11 +37,16 @@
37
37
  * [planned]. The EXACT-O(log n) fewest-in-flight variant is a deferred @zakkster/lite-logn
38
38
  * BinaryHeap optional-peer seam (decisions/0006), not this exact-O(cap) scan.
39
39
  *
40
+ * M5 (0.5.0) adds the ergonomic request layer at the @zakkster/lite-pick/pool subpath (a
41
+ * SEPARATE file, Pool.js -- the async dispatch/settle counter wrapper + distinct-endpoint
42
+ * failover + a duck-typed query-cache fetcher). This kernel file stays PURE and 0 B/op; the
43
+ * async Pool lives outside it (decisions/0007, the lite-query /stream + /await subpath precedent).
44
+ *
40
45
  * Zero runtime dependencies. node:test only. ESM, single file, tree-shakeable.
41
46
  */
42
47
 
43
48
  /** Version stamp. Synced across package.json and llms.txt (three-place rule). */
44
- export const VERSION = '0.4.0';
49
+ export const VERSION = '0.5.0';
45
50
 
46
51
  /**
47
52
  * Fail-closed sentinel returned by pick() when no endpoint is eligible.
package/Pool.d.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @zakkster/lite-pick/pool -- TypeScript declarations (M5).
3
+ *
4
+ * The async request layer over the 0 B/op kernel: dispatch/settle in-flight counter
5
+ * ergonomics + distinct-endpoint failover, plus a duck-typed query-cache fetcher adapter.
6
+ */
7
+
8
+ /** The source-of-truth version stamp (re-exported from the core). */
9
+ export const VERSION: string;
10
+
11
+ /** The minimal balancer shape Pool drives (any lite-pick strategy satisfies it). */
12
+ export interface Balancer {
13
+ pick(): number;
14
+ readonly capacity: number;
15
+ readonly live: number;
16
+ }
17
+
18
+ /** Options for `Pool.run`. */
19
+ export interface RunOptions {
20
+ /** Passed to `fn`; when already aborted after a failure, stops failover (abort propagates). */
21
+ signal?: AbortSignal;
22
+ /** Max distinct-endpoint attempts (default 1 = no failover). */
23
+ tries?: number;
24
+ }
25
+
26
+ /**
27
+ * Pool -- wraps a balancer + the caller-owned in-flight view with dispatch/settle counter
28
+ * ergonomics and distinct-endpoint failover. `run` increments in-flight on dispatch, decrements
29
+ * on settle, and on a thrown error keeps the failed endpoint elevated so a load-aware strategy
30
+ * steers the next attempt elsewhere. NOT a 0 B/op path (the kernel `pick()` is).
31
+ */
32
+ export class Pool {
33
+ /**
34
+ * @param balancer a lite-pick strategy (or duck-compatible) with `pick()`, `capacity`, `live`.
35
+ * @param inflight the SAME caller-owned in-flight view the balancer reads (length >= capacity).
36
+ */
37
+ constructor(balancer: Balancer, inflight: Uint32Array);
38
+ /** The wrapped balancer. */
39
+ readonly balancer: Balancer;
40
+ /** The shared in-flight view Pool increments on dispatch and decrements on settle. */
41
+ readonly inflight: Uint32Array;
42
+ /**
43
+ * Run `fn` against a chosen endpoint (in-flight incremented on dispatch, decremented on
44
+ * settle), with up to `opts.tries` distinct-endpoint failover attempts on a throw. Rejects
45
+ * with a `LITE_PICK_NONE`-coded error when no endpoint is eligible, or the last error when
46
+ * every attempt fails.
47
+ */
48
+ run<T>(fn: (endpoint: number, signal?: AbortSignal) => Promise<T> | T, opts?: RunOptions): Promise<T>;
49
+ }
50
+
51
+ /** Context passed to the per-endpoint fetcher. */
52
+ export interface PerEndpointContext {
53
+ endpoint: number;
54
+ key: any;
55
+ signal?: AbortSignal;
56
+ }
57
+
58
+ /** Context a query cache passes to the produced fetcher (lite-query's fetcher shape). */
59
+ export interface FetcherContext {
60
+ key: any;
61
+ signal?: AbortSignal;
62
+ }
63
+
64
+ /** Options for `liteQueryFetcher`. */
65
+ export interface FetcherOptions {
66
+ /** Spatial failover attempts across the pool (default 1). */
67
+ tries?: number;
68
+ }
69
+
70
+ /**
71
+ * Adapt a Pool into a `({ key, signal }) => Promise` fetcher for a query cache (lite-query, or
72
+ * any fetcher-shaped consumer). Imports nothing from lite-query -- duck-typed, zero peers.
73
+ */
74
+ export function liteQueryFetcher<T>(
75
+ pool: Pool,
76
+ perEndpoint: (ctx: PerEndpointContext) => Promise<T> | T,
77
+ opts?: FetcherOptions,
78
+ ): (ctx: FetcherContext) => Promise<T>;
package/Pool.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @zakkster/lite-pick/pool -- the ergonomic request wrapper (M5).
3
+ *
4
+ * import { Pool, liteQueryFetcher } from '@zakkster/lite-pick/pool';
5
+ *
6
+ * The kernel (Pick.js) is a PURE, 0 B/op selector: pick() -> index. Real callers also need
7
+ * the counter ergonomics ADR 0001 always promised -- increment in-flight on DISPATCH,
8
+ * decrement on SETTLE, and on a failure re-pick a DIFFERENT endpoint. That layer is async
9
+ * (it wraps the request lifecycle), so it lives OUTSIDE the single-file 0 B/op kernel, in
10
+ * this separate subpath file (the lite-query precedent: /stream, /await are subpath entries).
11
+ *
12
+ * BOUNDARY (decisions/0007): Pool owns SPATIAL failover -- try up to `tries` DISTINCT endpoints,
13
+ * once each, on a thrown error. It does NOT own TEMPORAL retry (backoff, staleness) -- that
14
+ * belongs to the caller / a query cache (lite-query's `retry`). The two never double-own: Pool
15
+ * moves ACROSS the pool once; the caller retries the whole operation over TIME.
16
+ *
17
+ * ZERO-GC boundary: the kernel `pick()` is 0 B/op; `Pool.run` is a NORMAL async wrapper -- the
18
+ * request it wraps already allocates a promise -- adding only O(1) integer counter ops per
19
+ * attempt plus one small per-run bookkeeping array. It is NOT held to the kernel's 0 B/op bar.
20
+ *
21
+ * Duck-typed, zero HARD deps, zero peers: `liteQueryFetcher` returns a value shaped like
22
+ * lite-query's `fetcher` (`({ key, signal }) => Promise`) WITHOUT importing lite-query, so
23
+ * `peerDependencies` stays empty and the same helper serves any fetcher-shaped consumer.
24
+ */
25
+
26
+ import { VERSION, PICK_NONE } from './Pick.js';
27
+
28
+ /** Re-exported so a /pool-only importer can read the version without importing the core. */
29
+ export { VERSION };
30
+
31
+ /**
32
+ * Pool -- wraps a balancer + the caller-owned in-flight view with the dispatch/settle counter
33
+ * ergonomics and distinct-endpoint failover. The balancer is duck-typed (anything with
34
+ * `pick() -> number`, `capacity`, and `live`), so a Pool can drive any lite-pick strategy or a
35
+ * compatible custom one.
36
+ */
37
+ export class Pool {
38
+ /**
39
+ * @param {{ pick(): number, capacity: number, live: number }} balancer a lite-pick
40
+ * strategy (RoundRobin / SmoothWRR / P2C / LeastConn / SED / NQ) or a duck-compatible one.
41
+ * @param {Uint32Array} inflight the SAME caller-owned in-flight view the balancer reads
42
+ * (length >= balancer.capacity). Pool is the increment/decrement authority around run().
43
+ */
44
+ constructor(balancer, inflight) {
45
+ if (!balancer || typeof balancer.pick !== 'function' ||
46
+ typeof balancer.capacity !== 'number' || typeof balancer.live !== 'number') {
47
+ throw new TypeError('[lite-pick] Pool needs a balancer with pick(), capacity, and live');
48
+ }
49
+ if (!(inflight instanceof Uint32Array) || inflight.length < balancer.capacity) {
50
+ throw new RangeError('[lite-pick] inflight must be a Uint32Array of length >= balancer.capacity');
51
+ }
52
+ this._b = balancer;
53
+ this._inflight = inflight;
54
+ }
55
+
56
+ /** The wrapped balancer. */
57
+ get balancer() {
58
+ return this._b;
59
+ }
60
+
61
+ /** The shared in-flight view Pool increments on dispatch and decrements on settle. */
62
+ get inflight() {
63
+ return this._inflight;
64
+ }
65
+
66
+ /**
67
+ * Run `fn` against a chosen endpoint, incrementing its in-flight on dispatch and decrementing
68
+ * on settle. On a thrown error, keep the failed endpoint's count ELEVATED and re-pick -- so a
69
+ * load-aware strategy (P2C / LeastConn / SED / NQ) naturally steers the next attempt to a
70
+ * DIFFERENT endpoint -- up to `tries` attempts, then throw the last error. All counts this run
71
+ * raised are released before returning or throwing (net-zero per run).
72
+ *
73
+ * @template T
74
+ * @param {(endpoint: number, signal?: AbortSignal) => (Promise<T>|T)} fn the per-endpoint work.
75
+ * @param {{ signal?: AbortSignal, tries?: number }} [opts] `tries` (default 1 = no failover)
76
+ * is the max number of distinct-endpoint attempts; `signal` is passed to `fn` and, when
77
+ * already aborted after a failure, stops failover (the abort propagates, no re-pick).
78
+ * @returns {Promise<T>}
79
+ */
80
+ async run(fn, opts) {
81
+ if (typeof fn !== 'function') throw new TypeError('[lite-pick] Pool.run needs a function');
82
+ const rawTries = opts && opts.tries != null ? (opts.tries | 0) : 1;
83
+ const tries = rawTries > 0 ? rawTries : 1;
84
+ const signal = opts ? opts.signal : undefined;
85
+ const inflight = this._inflight, b = this._b;
86
+ const held = []; // endpoints incremented this run (kept elevated across failover)
87
+ let lastErr;
88
+ try {
89
+ for (let attempt = 0; attempt < tries; attempt++) {
90
+ const i = b.pick();
91
+ if (i === PICK_NONE) {
92
+ if (attempt === 0) {
93
+ const e = new Error('[lite-pick] no eligible endpoint');
94
+ e.code = 'LITE_PICK_NONE';
95
+ throw e;
96
+ }
97
+ break; // pool went fully down mid-failover: surface the last error
98
+ }
99
+ inflight[i] = (inflight[i] + 1) >>> 0;
100
+ held.push(i);
101
+ try {
102
+ return await fn(i, signal);
103
+ } catch (err) {
104
+ lastErr = err;
105
+ if (signal && signal.aborted) throw err; // abort: stop failover, propagate
106
+ }
107
+ // keep inflight[i] elevated so the next pick() steers to a different endpoint
108
+ }
109
+ throw lastErr;
110
+ } finally {
111
+ for (let k = 0; k < held.length; k++) {
112
+ const j = held[k];
113
+ inflight[j] = inflight[j] > 0 ? inflight[j] - 1 : 0;
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * liteQueryFetcher -- adapt a Pool into a fetcher for a query cache (lite-query's `fetcher`, or
121
+ * any `({ key, signal }) => Promise` consumer). Duck-typed: imports NOTHING from lite-query.
122
+ *
123
+ * const fetcher = liteQueryFetcher(pool, ({ endpoint, key, signal }) =>
124
+ * fetch(urls[endpoint] + '/' + key[0], { signal }).then(r => r.json()), { tries: 2 });
125
+ * query(qc, { key: ['users'], fetcher });
126
+ *
127
+ * The query cache owns TEMPORAL retry/backoff/staleness; the Pool owns SPATIAL failover across
128
+ * the pool (`tries`). Wiring both is deliberate layering, never double-ownership (ADR 0007).
129
+ *
130
+ * @template T
131
+ * @param {Pool} pool
132
+ * @param {(ctx: { endpoint: number, key: any, signal?: AbortSignal }) => (Promise<T>|T)} perEndpoint
133
+ * @param {{ tries?: number }} [opts] spatial failover attempts (default 1).
134
+ * @returns {(ctx: { key: any, signal?: AbortSignal }) => Promise<T>}
135
+ */
136
+ export function liteQueryFetcher(pool, perEndpoint, opts) {
137
+ if (!(pool instanceof Pool)) throw new TypeError('[lite-pick] liteQueryFetcher needs a Pool');
138
+ if (typeof perEndpoint !== 'function') {
139
+ throw new TypeError('[lite-pick] liteQueryFetcher needs a per-endpoint function');
140
+ }
141
+ const tries = opts && opts.tries != null ? opts.tries : 1;
142
+ return function fetcher(ctx) {
143
+ const key = ctx ? ctx.key : undefined;
144
+ const signal = ctx ? ctx.signal : undefined;
145
+ return pool.run((endpoint, sig) => perEndpoint({ endpoint, key, signal: sig }), { signal, tries });
146
+ };
147
+ }
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-pick
2
2
 
3
- > Zero-GC load-balancing **selection kernel**: one hot `pick()` that returns an endpoint **index** over a fixed pool and allocates **0 B/op** on the steady-state path. A pure selector, never a proxy -- it consumes health and circuit state, it never owns them. **v0.4.0 ships six strategies -- `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, and the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family** -- on the substrate seams (`VERSION`, `PICK_NONE`, a deterministic `Prng`, and `BalancerBase`'s shared read-only eligibility view). The rest of the roster -- PeakEWMA, ConsistentHash, BoundedLoad, WeightedRandom -- lands one per session.
3
+ > Zero-GC load-balancing **selection kernel**: one hot `pick()` that returns an endpoint **index** over a fixed pool and allocates **0 B/op** on the steady-state path. A pure selector, never a proxy -- it consumes health and circuit state, it never owns them. **v0.5.0 ships six strategies -- `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, and the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family** -- on the substrate seams (`VERSION`, `PICK_NONE`, a deterministic `Prng`, and `BalancerBase`'s shared read-only eligibility view), plus a **`@zakkster/lite-pick/pool`** subpath: the async dispatch/settle counter layer with distinct-endpoint failover and a duck-typed query-cache fetcher. The rest of the roster -- PeakEWMA, ConsistentHash, BoundedLoad, WeightedRandom -- lands one per session.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@zakkster/lite-pick.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-pick)
6
6
  [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
@@ -21,7 +21,7 @@ The npm landscape has old algorithm libraries (`load-balancers`, `loadbalance`,
21
21
  - **Two pieces of evidence, both shipped.** A **0 B/op** witness on the pick path (no object, closure, string, or array created per pick), and a measured **balance-quality anchor** -- peak-to-average load within the strategy's theoretical ceiling (for P2C, the Azar-Broder-Karlin-Upfal `ln ln n / ln 2` bound) and strictly better than a random foil.
22
22
  - **A pure selector, not a proxy.** It **consumes** health and circuit state; it never owns them. Health is a shared read-only bitmap written by [`@zakkster/lite-di-health`](https://www.npmjs.com/package/@zakkster/lite-di-health); circuit state comes from [`@zakkster/lite-statechart`](https://www.npmjs.com/package/@zakkster/lite-statechart); load counters are caller-owned typed arrays. `pick()` only reads.
23
23
 
24
- > **Status: M4 (v0.4.0).** Ships the substrate seams **plus `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, and the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family**. Every strategy is gated: `pick()` proven **0 B/op** (torture + PerfGate), RoundRobin **perfectly fair** with **zero dead picks** vs the naive `i++ % n` foil, SmoothWRR **exactly weighted** and **smooth**, **P2C proves the `ln ln n` balance ceiling** (peak-to-mean gap ~2 vs a random foil's ~21 at n=1024), **LeastConn is greedy-perfect** (max-minus-min load <= 1), and **SED tracks weight within 1%**. New in M4: a **seeded invariant fuzzer** (`test/fuzz.mjs`) that asserts each strategy's state-synchronisation invariants after *every* op. See [ROADMAP.md](./ROADMAP.md) for the M4 -> M10 path to 1.0.0, and [decisions/](./decisions) for the ownership boundary (ADR 0001), anti-flapping (ADR 0002), and the RoundRobin (0003), SmoothWRR (0004), P2C (0005), and LeastConn-family (0006) design forks.
24
+ > **Status: M5 (v0.5.0).** Ships the substrate seams **plus `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, and the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family**, and the **`@zakkster/lite-pick/pool`** request layer. Every strategy is gated: `pick()` proven **0 B/op** (torture + PerfGate), RoundRobin **perfectly fair** with **zero dead picks** vs the naive `i++ % n` foil, SmoothWRR **exactly weighted** and **smooth**, **P2C proves the `ln ln n` balance ceiling** (peak-to-mean gap ~2 vs a random foil's ~21 at n=1024), **LeastConn is greedy-perfect** (max-minus-min load <= 1), and **SED tracks weight within 1%** -- all held under a **seeded invariant fuzzer** (`test/fuzz.mjs`) that checks state-synchronisation after *every* op. See [ROADMAP.md](./ROADMAP.md) for the M5 -> M10 path to 1.0.0, and [decisions/](./decisions) for the ownership boundary (ADR 0001), anti-flapping (ADR 0002), and the RoundRobin (0003), SmoothWRR (0004), P2C (0005), LeastConn-family (0006), and pool-adapter (0007) design forks.
25
25
 
26
26
  ```bash
27
27
  npm install @zakkster/lite-pick
@@ -169,6 +169,37 @@ VERSION; // -> '0.4.0'
169
169
 
170
170
  `BalancerBase.pick()` is **abstract** -- it throws, so an unfinished strategy fails loudly rather than returning a dead index. Every shipped strategy (`RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, `LeastConnBalancer`, `SedBalancer`, `NqBalancer`) extends it and reads the same shared eligibility view; you subclass it the same way to add your own.
171
171
 
172
+ ## Wiring it up -- `@zakkster/lite-pick/pool` (v0.5.0)
173
+
174
+ The kernel gives you `pick() -> index`. Real callers also need the counter ergonomics: **increment in-flight on dispatch, decrement on settle, and re-pick a *different* endpoint on failure.** That layer is async (it wraps the request), so it lives in a separate subpath -- `@zakkster/lite-pick/pool` -- and the kernel stays 0 B/op.
175
+
176
+ ```js
177
+ import { LeastConnBalancer } from '@zakkster/lite-pick';
178
+ import { Pool, liteQueryFetcher } from '@zakkster/lite-pick/pool';
179
+
180
+ const eligible = Uint8Array.from([1, 1, 1, 1]);
181
+ const inflight = new Uint32Array(4);
182
+ const balancer = new LeastConnBalancer(4, eligible, inflight);
183
+ const pool = new Pool(balancer, inflight); // Pool is the inc/dec authority around run()
184
+
185
+ // run(): pick -> inflight++ -> await fn -> inflight-- (in a finally). tries=2 re-picks a
186
+ // DIFFERENT endpoint if the first throws (a load-aware strategy steers off the failed node).
187
+ const res = await pool.run((i, signal) => fetch(urls[i], { signal }), { tries: 2 });
188
+
189
+ // Drop-in for a query cache (lite-query, or any `({ key, signal }) => Promise` fetcher).
190
+ // Duck-typed -- imports NOTHING from lite-query, so peerDependencies stays empty.
191
+ const fetcher = liteQueryFetcher(pool,
192
+ ({ endpoint, key, signal }) => fetch(urls[endpoint] + '/' + key[0], { signal }).then(r => r.json()),
193
+ { tries: 2 });
194
+ // query(qc, { key: ['users'], fetcher });
195
+ ```
196
+
197
+ **Two layers, no overlap** ([ADR 0007](./decisions/0007-pool-adapter.md)): the pool owns **spatial** failover (try a different endpoint *now*); your query cache owns **temporal** retry (backoff, staleness). `run()` is a normal async wrapper -- it adds O(1) counter ops per attempt, **it is not held to the kernel's 0 B/op bar** (that's `pick()`). See it end-to-end -- least-conn fan-out over a flaky pool with a node killed mid-run, proving 0 dead picks and 0 leaked in-flight:
198
+
199
+ ```bash
200
+ npm run demo
201
+ ```
202
+
172
203
  ## Design ownership (ratified before any strategy)
173
204
 
174
205
  lite-pick owns **no mutable state it can avoid owning** ([ADR 0001](./decisions/0001-selection-kernel-boundary.md)):
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-pick
2
2
 
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  License: MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
5
5
  Runtime dependencies: none. ESM only. ASCII-only source. sideEffects: false.
6
6
  Node: >= 18.
@@ -107,6 +107,35 @@ n) fewest-in-flight variant is a deferred @zakkster/lite-logn `BinaryHeap` optio
107
107
  exists -- never queueing while a server is free -- else the SED minimum, else `PICK_NONE`.
108
108
  O(cap) worst case, O(1) when an early node is idle. 0 B/op.
109
109
 
110
+ ## Subpath: @zakkster/lite-pick/pool -- the ergonomic request layer (M5, Pool.js)
111
+
112
+ The kernel is a PURE 0 B/op selector; `/pool` is the ASYNC layer over it -- dispatch/settle
113
+ in-flight counter ergonomics + distinct-endpoint failover + a duck-typed query-cache fetcher.
114
+ It is a SEPARATE subpath file (the lite-query /stream + /await precedent), NOT part of the
115
+ 0 B/op single-file kernel (decisions/0007). Zero HARD deps, zero peers: the fetcher adapter is
116
+ duck-typed and imports NOTHING from lite-query.
117
+
118
+ - `VERSION` -- string. Re-exported from the core (same three-place stamp).
119
+ - `Pool` -- class. Wraps a balancer + the caller-owned in-flight view.
120
+ - `new Pool(balancer, inflight)` -- `balancer` is duck-typed (any `{ pick(): number, capacity,
121
+ live }` -- every lite-pick strategy qualifies); `inflight` is the SAME caller-owned Uint32Array
122
+ the balancer reads (length >= balancer.capacity). Throws on a bad balancer / undersized view.
123
+ - `balancer` / `inflight` -- readonly getters.
124
+ - `run(fn, opts?)` -> Promise. Picks an endpoint, increments its in-flight on DISPATCH, awaits
125
+ `fn(endpoint, signal)`, decrements on SETTLE (in a finally -- net-zero per run, even on throw).
126
+ On a thrown error it keeps the failed endpoint's count ELEVATED and re-picks, so a load-aware
127
+ strategy (P2C/LeastConn/SED/NQ) steers the next attempt to a DIFFERENT endpoint -- up to
128
+ `opts.tries` attempts (default 1 = no failover), then rejects with the LAST error. Rejects with
129
+ a `code:'LITE_PICK_NONE'` error when no endpoint is eligible. `opts.signal` is passed to `fn`;
130
+ once aborted after a failure, failover stops and the abort propagates. NOT a 0 B/op path (the
131
+ kernel `pick()` is): a normal async wrapper adding O(1) counter ops + one small per-run array.
132
+ BOUNDARY: Pool owns SPATIAL failover (across the pool); the caller / query cache owns TEMPORAL
133
+ retry (backoff, staleness). Never double-owned (decisions/0007).
134
+ - `liteQueryFetcher(pool, perEndpoint, opts?)` -> a `({ key, signal }) => Promise` fetcher for a
135
+ query cache (lite-query's `fetcher`, or any fetcher-shaped consumer). `perEndpoint({ endpoint,
136
+ key, signal })` -> the per-endpoint work. `opts.tries` (default 1) is the spatial failover count.
137
+ Imports nothing from lite-query -- duck-typed, so `peerDependencies` stays empty.
138
+
110
139
  ## Gates (every session)
111
140
 
112
141
  - `npm run torture` -- `node --expose-gc test/torture.mjs`: lite-leak retention +
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@zakkster/lite-pick",
3
3
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
4
- "version": "0.4.0",
5
- "description": "Zero-dependency, zero-GC load-balancing selection kernel: one hot pick() -> endpoint index over a fixed pool, 0 B/op steady-state. A pure selector (consumes health/circuit state, never a proxy) for the in-process hop, complementary to AWS NLB/ALB. Tree-shakeable ESM roster: RoundRobin, SmoothWRR, P2C, LeastConn, SED, NQ, plus PeakEWMA and consistent hashing.",
4
+ "version": "0.5.0",
5
+ "description": "Zero-dependency, zero-GC load-balancing selection kernel: one hot pick() -> endpoint index over a fixed pool, 0 B/op steady-state. A pure selector (consumes health/circuit state, never a proxy) for the in-process hop, complementary to AWS NLB/ALB. Tree-shakeable ESM roster: RoundRobin, SmoothWRR, P2C, LeastConn, SED, NQ, plus PeakEWMA and consistent hashing; the /pool subpath adds dispatch/settle counters + failover and a duck-typed query-cache fetcher.",
6
6
  "type": "module",
7
7
  "main": "./Pick.js",
8
8
  "module": "./Pick.js",
@@ -13,11 +13,19 @@
13
13
  "node": "./Pick.js",
14
14
  "import": "./Pick.js",
15
15
  "default": "./Pick.js"
16
+ },
17
+ "./pool": {
18
+ "types": "./Pool.d.ts",
19
+ "node": "./Pool.js",
20
+ "import": "./Pool.js",
21
+ "default": "./Pool.js"
16
22
  }
17
23
  },
18
24
  "files": [
19
25
  "Pick.js",
20
26
  "Pick.d.ts",
27
+ "Pool.js",
28
+ "Pool.d.ts",
21
29
  "llms.txt",
22
30
  "README.md",
23
31
  "CHANGELOG.md",
@@ -33,6 +41,7 @@
33
41
  "test:perf": "node --expose-gc --max-semi-space-size=4 --test test/perf/PerfGate.test.mjs",
34
42
  "bench": "node benchmark/Matrix.mjs",
35
43
  "bench:report": "node benchmark/Matrix.mjs && node benchmark/Report.mjs",
44
+ "demo": "node demo/fanout.mjs",
36
45
  "verify": "npm test && npm run test:types && npm run torture && npm run witness && npm run balance && npm run fuzz && npm run test:perf"
37
46
  },
38
47
  "keywords": [