first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,108 @@
# HybridDiscoveryManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Top-level orchestrator composing any subset of the five Wave-2 sibling discovery strategies (peerjs / crdt / trystero / webtorrent / gundb) into a single composite that itself implements the `@suaveplan/network-bootstrap` `DiscoveryStrategy` contract. The manager fans `discover()` out across every healthy strategy, merges the results through the `PeerTracker` for de-duplication with `PeerSource` provenance, and surfaces a unified `HybridManagerStatus` snapshot.
Because the manager implements `DiscoveryStrategy` itself, it can be chained into a higher-level `DiscoveryChain` from `@suaveplan/network-bootstrap` — the hybrid is effectively a recursive composer.
## Features
- Composes any subset of the five Wave-2 strategies plus arbitrary `"custom"` strategies registered via `StrategyConfig.strategy`.
- Priority-based peer selection (`priority` mode), weighted concurrent fan-out (`weighted`), or fastest-non-empty-wins race (`fastest`).
- Per-strategy `StrategyHealth` tracking via the embedded `StrategyManager` (rolling success-rate EMA, consecutive failures, last error, peer count).
- Automatic fallback on degradation — an unavailable primary triggers a dormant fallback/backup to initialise.
- De-duplicated peer set with `PeerSource` provenance (`discoveredBy` is an ordered list of every strategy that has ever observed the peer).
- Unified lifecycle (`start` / `stop` / `dispose`) — idempotent; `stop()` cancels every periodic loop atomically and clears the peer set.
- Periodic health-check + stale-peer sweep driven by `@suaveplan/timing` `Scheduler` with an injectable clock for deterministic tests.
- AbortSignal threaded through every async path; `discover(timeoutMs, signal)` honours both the caller's signal and the internal `discoveryTimeoutMs` cap.
- Optional `@suaveplan/events` `EventBusLike<HybridDiscoveryEventMap>` integration for typed observability.
- OTel surface: `hybrid.peer_sources_total`, `hybrid.strategy_health_score`, `hybrid.fallback_total`, `hybrid.active_strategies`, `hybrid.discover_duration_ms`.
## Usage: Basic Example
```typescript
import { HybridDiscoveryManager } from "@suaveplan/network-discovery-hybrid";
const manager = new HybridDiscoveryManager({
strategy: "hybrid",
strategies: [
{ id: "peerjs", priority: "primary", strategy: peerJsStrategy },
{ id: "trystero", priority: "fallback", strategy: trysteroStrategy },
],
});
await manager.start();
const peers = await manager.discover(5_000);
await manager.stop();
```
## Usage: Advanced Example
```typescript
import { createEventBus } from "@suaveplan/events";
import { HybridDiscoveryManager } from "@suaveplan/network-discovery-hybrid";
import type { HybridDiscoveryEventMap } from "@suaveplan/network-discovery-hybrid";
const bus = createEventBus<HybridDiscoveryEventMap>();
bus.on("hybrid:fallback", ({ from, to }) =>
console.log(`Fallback ${from} → ${to}`)
);
const manager = new HybridDiscoveryManager(
{
strategy: "hybrid",
loadBalancing: "fastest",
autoFallback: true,
deduplication: true,
discoveryTimeoutMs: 8_000,
peerStaleMs: 30_000,
maxPeers: 500,
strategies: [
{ id: "peerjs", priority: "primary", weight: 70 },
{
id: "trystero",
priority: "fallback",
weight: 30,
factory: async () => {
const mod = await import("@suaveplan/network-discovery-trystero");
return new mod.TrysteroDiscovery({ /* ... */ });
},
},
],
},
{ bus }
);
const controller = new AbortController();
await manager.start(controller.signal);
try {
const peers = await manager.discover(5_000, controller.signal);
console.log(`Discovered ${peers.length} peers`);
console.log(manager.getStatus());
} finally {
await manager.dispose();
}
```
## API Reference
- `new HybridDiscoveryManager(config, options?)` — config validated via Zod; throws `INVALID_OPTIONS` on shape failure or `NO_STRATEGIES` on empty `strategies`. `options.bus` accepts an `EventBusLike<HybridDiscoveryEventMap>`.
- `start(signal?)` — initialise every strategy in priority order. Throws `ALL_STRATEGIES_DEGRADED` when no strategy initialises successfully.
- `stop(signal?)` — stop every active strategy + cancel periodic loops. Idempotent.
- `dispose(signal?)` — drive a hard shutdown; subsequent methods throw `DISPOSED`.
- `discover(timeoutMs, signal?)` — fan out discovery, merge results into the deduped set, return the current snapshot. Honours both the supplied `signal` and the config-level `discoveryTimeoutMs` cap (whichever is shorter).
- `isAvailable(signal?)` — `true` when running and at least one strategy is healthy.
- `getStatus()` — snapshot of `running`, `activeStrategies`, `totalPeers`, `peersByStrategy`, `strategies`, `loadBalancing`.
- `getPeerCount()` — total deduped peer count.
## Implementation Notes
`discover()` is the hot path. It composes the caller's `signal` with an internal `setTimeout` that fires after `min(timeoutMs, config.discoveryTimeoutMs)` ms, propagates abort to every strategy via the composite signal, and always cleans up its `setTimeout` in the `finally` block so a fast resolve does not leak a timer. The composite signal's outcome (`ok` / `empty` / `error` / `timeout`) is folded into the `hybrid.discover_duration_ms` histogram.
`PeerTracker` is owned exclusively by the manager. The two callbacks wired into the tracker forward dedup events directly to the optional bus. The `PeerSource.discoveredBy` array is intentionally append-only — once a strategy has seen a peer, it remains in the provenance chain until either the peer loses every strategy or the peer is swept for being stale. This guarantees provenance is a monotonic record per peer.
`Lifecycle` is started inside `start()` only after at least one strategy is active. This avoids a brief window where the health-check loop could fire before any strategy is registered and produce a no-op tick.

View File

@@ -0,0 +1,73 @@
# Lifecycle
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Self-rearming periodic loops orchestrating the hybrid manager's background work — running health checks against every registered strategy and sweeping stale peers from the `PeerTracker`. Replaces the upstream `setInterval` triple with a single injectable `Scheduler` from `@suaveplan/timing` so disposal is atomic and tests can drive the timeline deterministically.
The `Lifecycle` is a thin loop runner — it does not own any peer state, does not know about strategy health, and never communicates with the bus. The orchestrator (`HybridDiscoveryManager`) wires the loop callbacks to the `StrategyManager` and `PeerTracker` instances it owns.
## Features
- Two self-rearming loops: health check + stale-peer sweep.
- `Scheduler`-driven with injectable `clock` for deterministic tests.
- Idempotent `start()` and `stop()`.
- `stop()` disposes the scheduler atomically — pending timers are cancelled in one operation.
- Health-check failures are absorbed (the underlying `StrategyManager` records them into per-strategy health).
## Usage: Basic Example
```typescript
import { Lifecycle } from "@suaveplan/network-discovery-hybrid";
const lifecycle = new Lifecycle({
clock: () => Date.now(),
healthCheckIntervalMs: 30_000,
cleanupIntervalMs: 30_000,
runHealthCheck: () => strategyManager.runHealthCheck(),
sweepStalePeers: () => peerTracker.sweepStale(60_000),
});
lifecycle.start();
// ... later
lifecycle.stop();
```
## Usage: Advanced Example
```typescript
import { Lifecycle } from "@suaveplan/network-discovery-hybrid";
import { createScheduler } from "@suaveplan/timing";
// Inject a custom scheduler factory — useful for tests that want to
// install a manual clock and drain the queue on demand.
const lifecycle = new Lifecycle(
{
clock,
healthCheckIntervalMs: 1_000,
cleanupIntervalMs: 5_000,
runHealthCheck: async () => mgr.runHealthCheck(),
sweepStalePeers: () => tracker.sweepStale(60_000),
},
(c) => createScheduler({ clock: c })
);
lifecycle.start();
lifecycle.stop(); // safe to call multiple times
```
## API Reference
- `new Lifecycle(deps, schedulerFactory?)` — `deps` carries the clock, the two interval cadences, and the two callbacks. `schedulerFactory` defaults to `createScheduler` from `@suaveplan/timing`.
- `start()` — kick off both loops. Idempotent; no-op after `stop()`.
- `stop()` — dispose the scheduler. Idempotent.
- `isStarted` — `true` after `start()` has been called and before `stop()`.
- `isStopped` — `true` after `stop()` has been called.
## Implementation Notes
The two loops use `Scheduler.schedule` (one-shot) and re-arm themselves after every fire. This is preferable to `setInterval` because it avoids the "tick storm" where a slow health-check holds the runtime past its next-tick boundary and the scheduler enqueues multiple back-to-back fires. A re-arming one-shot only schedules the next tick after the current one has settled.
`scheduleSafe` guards against `Scheduler.disposed` so calls that race a concurrent `stop()` silently no-op instead of throwing `TIMING.SCHEDULER.DISPOSED`. This is the safe choice — by the time the scheduler is disposed, the orchestrator has already torn down every other piece of state and there is nothing meaningful for a stray callback to do.

View File

@@ -0,0 +1,84 @@
# PeerTracker
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
In-memory, dedup-aware peer registry that records every peer observation from every registered discovery strategy alongside its `PeerSource` provenance. The tracker is a pure data structure with synchronous methods only — it does no I/O, owns no timers, and is safe to drop into any orchestrator that needs to merge concurrent peer streams from heterogeneous strategies.
## Features
- Deduplication by `PeerInfo.peerId` with append-only `discoveredBy` provenance.
- Stable `primaryStrategy` field — the first strategy to ever observe the peer.
- Graceful primary-strategy demotion on peer-loss when multiple strategies still see the peer.
- Configurable `maxPeers` with LRU-by-lastSeen eviction.
- `sweepStale(staleMs)` for ageing out peers whose `lastSeen` is older than the threshold.
- Per-strategy peer counts via `countFor(strategy)`.
- Injectable `Clock` for deterministic tests.
- Optional `onPeerDiscovered` / `onPeerLost` callbacks for forwarding to a bus.
## Usage: Basic Example
```typescript
import { PeerTracker } from "@suaveplan/network-discovery-hybrid";
const tracker = new PeerTracker();
tracker.observe(
{
peerId: "alice",
addresses: [],
lastSeen: Date.now(),
source: "peerjs",
},
"peerjs"
);
console.log(tracker.size()); // 1
```
## Usage: Advanced Example
```typescript
import { PeerTracker } from "@suaveplan/network-discovery-hybrid";
let clockNow = 1_000;
const tracker = new PeerTracker(
{
onPeerDiscovered: (peer, strategy) =>
console.log(`+${peer.peerId} via ${strategy}`),
onPeerLost: (peerId) => console.log(`-${peerId}`),
},
{ clock: () => clockNow, maxPeers: 5, dedup: true }
);
const peer = {
peerId: "alice",
addresses: [],
lastSeen: clockNow,
source: "peerjs",
};
tracker.observe(peer, "peerjs");
tracker.observe(peer, "trystero"); // dedupes; discoveredBy = ["peerjs", "trystero"]
clockNow = 100_000;
tracker.sweepStale(10_000); // ages out "alice"
```
## API Reference
- `new PeerTracker(callbacks?, options?)` — `callbacks` may include `onPeerDiscovered` and `onPeerLost`; `options` accepts `clock`, `maxPeers` (default 1000), `dedup` (default true).
- `observe(peer, strategy)` — record a peer observation. New peer → emits `onPeerDiscovered`; existing peer → appends `strategy` to `discoveredBy` and refreshes `lastSeen`.
- `lose(peerId, strategy)` — record a peer-loss from a strategy. Demotes primary when needed; removes the peer when no strategies remain (or always when `dedup: false`).
- `sweepStale(staleMs)` — return the number of peers removed for being older than `now - staleMs`.
- `snapshot()` — readonly snapshot of every `PeerSource`.
- `peers()` — readonly snapshot of every `PeerInfo`.
- `size()` — current peer count.
- `countFor(strategy)` — peer count attributable to `strategy`.
- `clear()` — drop every tracked peer (silently — does not fire callbacks).
## Implementation Notes
The `discoveredBy` array is intentionally an array, not a `Set`, so insertion order is observable — the primary strategy is always the first entry, and the second entry is the natural successor when the primary loses the peer. This makes the demotion logic deterministic without needing a separate priority register.
When `maxPeers` is hit, the tracker evicts the peer with the smallest `lastSeen` value (least-recently-seen). This is preferable to FIFO because a long-lived peer that has been re-observed many times should outlive a fresh peer that was seen once and never again. The eviction emits `onPeerLost` so downstream listeners can react.

View File

@@ -0,0 +1,55 @@
# Strategy Loader
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Resolves a registered `StrategyConfig` to a concrete `DiscoveryStrategy` instance from `@suaveplan/network-bootstrap`. The loader bridges the two registration paths supported by the hybrid orchestrator: pre-built strategies (handed in directly for tests and statically-imported production code) and lazy factories (for optional peer-deps resolved via dynamic `import()`).
The loader is the single component that knows how to convert configuration into a live strategy, and it is responsible for translating every failure mode into the typed `STRATEGY_LOAD_FAILED` code so the orchestrator can branch on a stable contract.
## Features
- Two registration paths: pre-built `strategy` or lazy `factory`.
- Wraps every thrown error (sync or async) in `NetDiscHybridError(STRATEGY_LOAD_FAILED)` with `cause` preserved and `context.id` set to the strategy id.
- No static import of sibling discovery packages — optional peer-deps stay optional.
- Returns the resolved strategy synchronously when `config.strategy` is already provided (no Promise round-trip).
## Usage: Basic Example
```typescript
import { loadStrategy } from "@suaveplan/network-discovery-hybrid";
const strategy = await loadStrategy({
id: "peerjs",
priority: "primary",
strategy: prebuiltPeerJsStrategy,
});
```
## Usage: Advanced Example
```typescript
import { loadStrategy } from "@suaveplan/network-discovery-hybrid";
// Factory pattern — peer-dep is resolved lazily via dynamic import()
const strategy = await loadStrategy({
id: "trystero",
priority: "fallback",
factory: async () => {
const mod = await import("@suaveplan/network-discovery-trystero");
return new mod.TrysteroDiscovery({ /* ... */ });
},
});
```
## API Reference
- `loadStrategy(config)` — async. Returns the resolved `DiscoveryStrategy`. Throws `NetDiscHybridError(STRATEGY_LOAD_FAILED)` when both `strategy` and `factory` are absent, when the factory throws, or when the factory rejects.
## Implementation Notes
The loader does not memoise — every call to `loadStrategy` invokes `factory()` fresh. Memoisation is the orchestrator's responsibility (it caches the resolved strategy in the `StrategyManager` so subsequent `start()` / `stop()` operate against the same instance). This separation keeps the loader simple and testable.
Optional peer-deps are intentionally not imported statically by this module — the caller's factory is responsible for the dynamic `import()` and is the natural place to catch `ERR_MODULE_NOT_FOUND` and translate it into a more specific user-facing message. The loader's job is just to lift any thrown failure into the structured-error wire format.

View File

@@ -0,0 +1,81 @@
# StrategyManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Per-strategy lifecycle, health bookkeeping, and priority-based fallback orchestration. The `StrategyManager` owns the `StrategyHealth` record for every registered strategy, drives `initializeAll()` / `stopAll()`, fans `discover()` calls out concurrently or under a fastest-wins race, and runs the periodic `runHealthCheck()` step that triggers fallback when a primary degrades.
It is a pure orchestration component — it never instantiates timers itself (the `Lifecycle` schedules health checks against it) and never speaks directly to a bus (it surfaces transitions through opt-in callbacks the `HybridDiscoveryManager` wires to an `EventBusLike`).
## Features
- Three-bucket priority ordering: `primary` → `fallback` → `backup`.
- Lazy strategy load via `loadStrategy`; load failures recorded into `StrategyHealth` without aborting the bootstrap.
- Rolling success-rate per strategy (0.7-weighted EMA) folded into OTel `hybrid.strategy_health_score`.
- `discoverAll()` supports `priority` / `weighted` fan-out and `fastest` race-to-first.
- `runHealthCheck()` calls `isAvailable()` on every live strategy and triggers `tryFallback()` when `autoFallback` is enabled.
- Active-strategy gauge incremented / decremented exactly once per lifecycle transition (OTel `hybrid.active_strategies`).
- `DUPLICATE_STRATEGY` thrown at construction when two configs share the same `id`.
- AbortSignal threaded through `initializeAll`, `stopAll`, `discoverAll`, and `runHealthCheck`.
## Usage: Basic Example
```typescript
import { StrategyManager } from "@suaveplan/network-discovery-hybrid";
const mgr = new StrategyManager({
strategy: "hybrid",
strategies: [{ id: "peerjs", priority: "primary", strategy: prebuilt }],
loadBalancing: "priority",
autoFallback: true,
deduplication: true,
discoveryTimeoutMs: 10_000,
peerStaleMs: 60_000,
maxPeers: 1000,
clock: () => Date.now(),
});
await mgr.initializeAll();
const byStrategy = await mgr.discoverAll();
```
## Usage: Advanced Example
```typescript
import { StrategyManager } from "@suaveplan/network-discovery-hybrid";
const mgr = new StrategyManager(resolvedConfig, {
onStrategyActive: (id) => bus.emit("hybrid:strategy-active", { id }),
onStrategyInactive: (id) => bus.emit("hybrid:strategy-inactive", { id }),
onStrategyFailed: (id, err) =>
bus.emit("hybrid:strategy-failed", { id, err }),
onFallback: (from, to) => bus.emit("hybrid:fallback", { from, to }),
});
const controller = new AbortController();
await mgr.initializeAll(controller.signal);
setInterval(() => mgr.runHealthCheck(controller.signal), 30_000);
```
## API Reference
- `new StrategyManager(config, callbacks?)` — `config` is `ResolvedHybridDiscoveryConfig`. Throws `DUPLICATE_STRATEGY` on dup ids.
- `initializeAll(signal?)` — lazy-load + start every strategy in priority order. Individual failures recorded, never thrown.
- `stopAll(signal?)` — stop every active strategy. Errors recorded, never thrown.
- `discoverAll(signal?)` — fan out discovery; returns `ReadonlyMap<StrategyId, readonly PeerInfo[]>`.
- `runHealthCheck(signal?)` — probe `isAvailable()` on every live strategy; trigger `tryFallback` when needed.
- `snapshotHealth()` — readonly array of `StrategyHealth`.
- `activeIds()` — list of active+healthy strategy ids.
- `updatePeerCount(id, count)` — update the per-strategy peer counter (called by the orchestrator after a `PeerTracker` sweep).
- `hasActiveStrategy()` — `true` when at least one strategy is active + healthy.
- `getEntry(id)` — read-only `{ config, health }` snapshot.
## Implementation Notes
The rolling success-rate uses an EMA with `α = 0.3` so a single failure drops the score noticeably without forgetting prior history. This score is the value recorded into `hybrid.strategy_health_score` and what dashboards should pivot on to spot a strategy degrading before it fully fails.
`raceFastest` is a hand-rolled `Promise` builder rather than `Promise.race` because the race must wait for a non-empty result, not just the first settlement — a strategy that resolves with `[]` should not win the race against a slower strategy that returns real peers. The builder tracks `outstanding` to detect "all strategies returned empty" and resolve with `null` so the orchestrator knows nothing was found.
Fallback walks the priority order (`fallback` before `backup`) and picks the first dormant entry. The `recordFallback` OTel counter is incremented exactly once per successful promotion so the `from→to` cardinality stays bounded to known strategy pairs.

View File

@@ -0,0 +1,74 @@
# Hybrid Discovery Errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Frozen error registry for `@suaveplan/network-discovery-hybrid`. Every public boundary throws `NetDiscHybridError` and consumers branch on `error.code`. The registry is the single contract the orchestrator exposes for failure modes — it is intentionally compact so consumers can exhaustively handle each case.
The registry is registered via `defineCodes` from `@suaveplan/error`, so the codes participate in the global error-code uniqueness gate enforced at build time. All codes share the `NET_DISC_HYBRID.` prefix.
## Features
- Six stable codes covering construction, lifecycle, and degradation failure paths.
- `NetDiscHybridError` subclasses `SuaveplanError<NetDiscHybridErrorCode>` so it integrates with the structured-error wire format, OTel span events, and i18n message resolution.
- Each thrown error carries an i18n message key (e.g. `errors.hybrid.invalid_options`) — the actual translation is resolved by the consumer's i18n stack.
- Includes the chained-cause when a Zod parse failure or a dynamic-import failure is the root cause.
## Usage: Basic Example
```typescript
import {
NET_DISC_HYBRID_CODES,
NetDiscHybridError,
} from "@suaveplan/network-discovery-hybrid";
try {
// ... call hybrid manager
} catch (error) {
if (error instanceof NetDiscHybridError) {
if (error.code === NET_DISC_HYBRID_CODES.NO_STRATEGIES) {
console.error("No strategies configured");
}
}
}
```
## Usage: Advanced Example
```typescript
import {
NET_DISC_HYBRID_CODES,
NetDiscHybridError,
} from "@suaveplan/network-discovery-hybrid";
function describe(err: NetDiscHybridError): string {
switch (err.code) {
case NET_DISC_HYBRID_CODES.NO_STRATEGIES:
return "no strategies were registered";
case NET_DISC_HYBRID_CODES.ALL_STRATEGIES_DEGRADED:
return "every strategy is unavailable";
case NET_DISC_HYBRID_CODES.STRATEGY_LOAD_FAILED:
return "an optional discovery peer-dep failed to load";
case NET_DISC_HYBRID_CODES.DUPLICATE_STRATEGY:
return "duplicate StrategyConfig.id detected";
case NET_DISC_HYBRID_CODES.INVALID_OPTIONS:
return "configuration failed Zod validation";
case NET_DISC_HYBRID_CODES.DISPOSED:
return "operation attempted after stop()";
}
}
```
## API Reference
- `NET_DISC_HYBRID_CODES` — frozen object with six keys (`NO_STRATEGIES`, `ALL_STRATEGIES_DEGRADED`, `STRATEGY_LOAD_FAILED`, `DUPLICATE_STRATEGY`, `INVALID_OPTIONS`, `DISPOSED`), each mapping to the namespaced string code.
- `NetDiscHybridErrorCode` — type union over the registry values.
- `NetDiscHybridError extends SuaveplanError<NetDiscHybridErrorCode>` — constructor `(code, messageKey, options?)`. Sets `name = "NetDiscHybridError"`.
## Implementation Notes
`STRATEGY_LOAD_FAILED` is intentionally non-terminal — the hybrid manager catches it internally, records the failure into the strategy's `StrategyHealth`, and continues with the remaining strategies. Only when every strategy has failed does the manager surface `ALL_STRATEGIES_DEGRADED` to the caller. This matches the upstream graceful-degradation behaviour and is what makes the orchestrator robust when an optional peer-dep is absent in the consumer's `node_modules`.
The six-code registry is the complete public surface. Any new failure path discovered during implementation should be classified into one of the existing codes rather than expanding the registry — the smaller the surface, the easier consumers can write exhaustive handlers.

View File

@@ -0,0 +1,72 @@
# Hybrid Discovery — OpenTelemetry Surface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes the OpenTelemetry instruments used by the hybrid orchestrator: counters for peer attribution and fallback events, a histogram for per-strategy health, an up-down counter for the active-strategy gauge, and span wrappers for `start()` and `discover()`. All instruments are namespaced under `@suaveplan/network-discovery-hybrid` so consumers can pivot dashboards by tracer / meter name.
Every public method in the orchestrator threads its observability through this module so the wire surface is uniform and easy to test (each instrument is a thin pass-through to the global OTel `meter` and `tracer`).
## Features
- `hybrid.peer_sources_total{strategy}` counter — incremented when a strategy contributes peers to the deduped set.
- `hybrid.strategy_health_score{strategy}` histogram — recorded after every `discover()` call with the rolling 0..1 success rate.
- `hybrid.fallback_total{from,to}` counter — recorded every time the orchestrator promotes a fallback past a degraded primary.
- `hybrid.active_strategies{strategy}` up-down counter — `+1` on activate, `-1` on deactivate; net gauge of currently-active strategies.
- `hybrid.discover_duration_ms{outcome}` histogram — wall-clock duration of `discover()` calls with outcome attribute (`ok` | `empty` | `error` | `timeout`).
- `withStartSpan` / `withDiscoverSpan` wrappers for span management.
## Usage: Basic Example
```typescript
import {
recordPeerSource,
recordStrategyHealth,
} from "@suaveplan/network-discovery-hybrid";
recordPeerSource("peerjs", 3);
recordStrategyHealth("peerjs", 0.95);
```
## Usage: Advanced Example
```typescript
import {
recordFallback,
recordActiveStrategyDelta,
withDiscoverSpan,
recordDiscoverDuration,
} from "@suaveplan/network-discovery-hybrid";
recordActiveStrategyDelta("trystero", 1);
recordFallback("peerjs", "trystero");
await withDiscoverSpan(async (_span) => {
const start = performance.now();
try {
// ... discovery work
recordDiscoverDuration(performance.now() - start, "ok");
} catch (e) {
recordDiscoverDuration(performance.now() - start, "error");
throw e;
}
});
```
## API Reference
- `recordPeerSource(strategy, count)` — increments `hybrid.peer_sources_total` by `count`. No-op when `count <= 0`.
- `recordStrategyHealth(strategy, score)` — records the rolling health score (0..1) for `strategy`.
- `recordFallback(from, to)` — increments `hybrid.fallback_total` with `{from, to}` attributes.
- `recordActiveStrategyDelta(strategy, delta)` — `delta ∈ {1, -1}`; tracks active-strategy gauge.
- `recordDiscoverDuration(ms, outcome)` — records `hybrid.discover_duration_ms` with the `outcome` attribute.
- `withStartSpan(fn)` — wraps `fn` in an OTel `hybrid.start` span.
- `withDiscoverSpan(fn)` — wraps `fn` in an OTel `hybrid.discover` span.
## Implementation Notes
The counters intentionally accept low-cardinality attributes only (`strategy` is one of six known IDs; `outcome` is one of four). High-cardinality data (per-peer ids, error messages) is never recorded — those go to span attributes, not metric attributes, to avoid blowing up the meter cardinality budget.
`recordActiveStrategyDelta` uses an `UpDownCounter` rather than a synchronous gauge so callers do not have to maintain a parallel observable-gauge callback. The orchestrator emits exactly one `+1` per activate and exactly one `-1` per deactivate, so the gauge stays accurate as long as those pair up — the lifecycle FSM in `lifecycle.ts` enforces that pairing.

View File

@@ -0,0 +1,78 @@
# Hybrid Discovery Types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the universal types consumed across the `@suaveplan/network-discovery-hybrid` package. The hybrid manager composes any subset of the five Wave-2 sibling discovery strategies behind a single `DiscoveryStrategy` contract from `@suaveplan/network-bootstrap`. This file is the single source of truth for the public surface of configuration, status, health, peer provenance, and bus-event payloads.
The types are intentionally framework-free and depend only on `@suaveplan/network-bootstrap` and `@suaveplan/types`. Concrete strategy implementations live in sibling packages and are pulled in lazily via the `StrategyLoader` so the universal consumer never pays for a strategy it does not register.
## Features
- `StrategyId` union covers the five shipped Wave-2 strategies plus a `"custom"` slot for caller-supplied strategies.
- `StrategyPriority` three-bucket model: `primary` / `fallback` / `backup`.
- `StrategyConfig` accepts either a pre-built `strategy` or a lazy `factory` so optional peer-deps can be resolved on demand.
- `HybridDiscoveryConfig` exposes `loadBalancing` (`priority` / `weighted` / `fastest`), `autoFallback`, `deduplication`, and tunable timeouts.
- `StrategyHealth` tracks active state, peer count, rolling success rate, consecutive failures, and last error.
- `PeerSource` records the de-duplicated peer with first-seen + last-seen provenance plus the ordered set of strategies that have observed it.
- `HybridDiscoveryEventMap` typed for use with `EventBusLike` from `@suaveplan/events`.
## Usage: Basic Example
```typescript
import type { HybridDiscoveryConfig } from "@suaveplan/network-discovery-hybrid/types";
const config: HybridDiscoveryConfig = {
strategy: "hybrid",
strategies: [
{ id: "peerjs", priority: "primary" },
{ id: "trystero", priority: "fallback" },
],
};
```
## Usage: Advanced Example
```typescript
import type {
HybridDiscoveryConfig,
StrategyHealth,
} from "@suaveplan/network-discovery-hybrid/types";
const config: HybridDiscoveryConfig = {
strategy: "hybrid",
loadBalancing: "weighted",
autoFallback: true,
deduplication: true,
discoveryTimeoutMs: 8000,
strategies: [
{ id: "peerjs", priority: "primary", weight: 70, timeoutMs: 4000 },
{ id: "trystero", priority: "fallback", weight: 30 },
{ id: "gundb", priority: "backup", weight: 10 },
],
};
function summarise(h: StrategyHealth): string {
return `${h.label}: ${h.active ? "active" : "idle"} (${h.peerCount} peers)`;
}
```
## API Reference
- `StrategyId` — `'peerjs' | 'trystero' | 'webtorrent' | 'crdt' | 'gundb' | 'custom'`.
- `StrategyPriority` — `'primary' | 'fallback' | 'backup'`.
- `StrategyConfig` — `{ id, priority, label?, weight?, timeoutMs?, strategy?, factory? }`. Either `strategy` or `factory` must be present (enforced by the schema).
- `HybridDiscoveryConfig` — top-level config. `strategies` is non-empty; all numeric tunables are optional with defaults.
- `ResolvedHybridDiscoveryConfig` — every optional field is materialised.
- `StrategyHealth` — per-strategy snapshot with `healthy`, `active`, `peerCount`, `lastDiscoverMs`, `successRate`, `consecutiveFailures`, `lastError?`, `lastCheck`.
- `PeerSource` — `{ peerId, discoveredBy, primaryStrategy, firstSeen, lastSeen, peer }`. `discoveredBy` is append-only and tracks every strategy that has seen the peer.
- `HybridManagerStatus` — `{ running, activeStrategies, totalPeers, peersByStrategy, strategies, loadBalancing }`.
- `HybridDiscoveryEventMap` — bus events keyed by topic. Use with `EventBusLike` from `@suaveplan/events`.
## Implementation Notes
The `StrategyConfig.strategy | factory` split is intentional. Tests register pre-built strategies directly; production code uses `factory` so the hybrid orchestrator can resolve a missing peer-dep with `STRATEGY_LOAD_FAILED` instead of crashing at module-evaluation time. The `clock` parameter on `HybridDiscoveryConfig` is a `() => number` per `@suaveplan/types` and is threaded into every timestamp-bearing call site so fast-check property tests can replay deterministic timelines.
`PeerSource.discoveredBy` is ordered — the first entry is always the `primaryStrategy`. When a strategy loses a peer, the strategy is filtered out of `discoveredBy`; if no strategies remain, the peer is removed entirely and `hybrid:peer-lost` fires. This matches the upstream behaviour while making provenance explicit at the type level.