Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
# @suaveplan/network-plugin-topology - Tiered network topology: hierarchical 3-tier model, latency-based clustering, group manager, plugin wrapper
## Summary
Tiered mesh topology plugin: 3-tier `HierarchicalTopology` (Host / SubHost / Leaf) with promote/demote, latency-bucket-based `LatencyAssigner`, `GroupManager`, plus stability primitives (reorganization circuit breaker, promotion hysteresis, stability-threshold gate) and empty-tier handling.
<!-- begin:auto-docs -->
## Documentation tree
- [group](./src/group/group-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [group-manager](./src/group/group-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [latency](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [latency-assigner](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [topology-plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [hierarchical-topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-topology plugin. Three codes cover the surface: `INVALID_TIER` fo…
<!-- end:auto-docs --> - [group-manager](./src/group/group-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [latency](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_- [latency-assigner](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [topology-plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_- [hierarchical-topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-topology plugin. Three codes cover the surface: `INVALID_TIER` fo…
- [group-manager](./src/group/group-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [latency](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_- [latency-assigner](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [topology-plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_- [hierarchical-topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-topology plugin. Three codes cover the surface: `INVALID_TIER` fo…
- [group-manager](./src/group/group-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [latency](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_- [latency-assigner](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [topology-plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_- [hierarchical-topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-topology plugin. Three codes cover the surface: `INVALID_TIER` fo…
- [group-manager](./src/group/group-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [latency](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_ - [latency-assigner](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [topology-plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_ - [hierarchical-topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-topology plugin. Three codes cover the surface: `INVALID_TIER` fo…

View File

@@ -0,0 +1,49 @@
{
"name": "@suaveplan/network-plugin-topology",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Tiered network topology: hierarchical 3-tier model, latency-based clustering, group manager, plugin wrapper",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/network": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -0,0 +1,62 @@
# NETWORK_TOPO_CODES
## Purpose
Frozen registry of error codes for the network-plugin-topology plugin. Three codes cover the surface: `INVALID_TIER` for out-of-range or non-integer tier inputs (and for nonsensical promote / demote directions), `PEER_NOT_FOUND` when a topology operation references an unknown peer, and `ALREADY_AT_TIER` when promote / demote is asked for a no-op. The companion `NetworkTopoError` extends `SuaveplanError<NetworkTopoErrorCode>` so consumers pattern-match on `code` rather than parse messages.
## Features
- Three frozen codes registered under the `NET_TOPO` namespace.
- `NetworkTopoError extends SuaveplanError<NetworkTopoErrorCode>`.
- `name: "NetworkTopoError"` on every instance — useful for log filters and dashboard alerts.
- `cause` and `context` propagation via `SuaveplanErrorOptions`.
- Codes are stable across versions; the registry only ever grows, never shrinks. `INVALID_TIER` is intentionally broad and reused across the topology, latency, and group sub-modules so the code surface stays small.
## Usage: Basic Example
```ts
import {
NETWORK_TOPO_CODES,
NetworkTopoError,
} from "@suaveplan/network-plugin-topology";
throw new NetworkTopoError(
NETWORK_TOPO_CODES.INVALID_TIER,
"topology.invalid-tier",
{ context: { tier: 5 } }
);
```
## Usage: Advanced Example
```ts
import {
NETWORK_TOPO_CODES,
NetworkTopoError,
} from "@suaveplan/network-plugin-topology";
try {
topology.promote("alice", 0);
} catch (err) {
if (err instanceof NetworkTopoError) {
if (err.code === NETWORK_TOPO_CODES.ALREADY_AT_TIER) {
return; // benign
}
if (err.code === NETWORK_TOPO_CODES.PEER_NOT_FOUND) {
await registerAndRetry(err);
return;
}
}
throw err;
}
```
## API Reference
- `NETWORK_TOPO_CODES` — frozen `{ INVALID_TIER, PEER_NOT_FOUND, ALREADY_AT_TIER }`.
- `type NetworkTopoErrorCode` — union of registered code values.
- `class NetworkTopoError extends SuaveplanError<NetworkTopoErrorCode>` — typed throwable.
## Implementation Notes
`ALREADY_AT_TIER` is a benign-but-distinct code: a caller asking to promote a peer that is already at the target tier is almost certainly a redundant request, but it should not silently succeed because that masks driver bugs in upstream election logic. Future stretch features (the proposal's resource / partition / geo / stability / empty-tier sub-systems) will likely want their own codes; until those land we keep the registry tight rather than register codes that never fire. The package barrel side-effect-imports `./error-codes.js` so subpath consumers register the codes too.

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_TOPO_CODES, NetworkTopoError } from "./error-codes.js";
describe("NETWORK_TOPO_CODES", () => {
it("registers under NET_TOPO", () => {
for (const value of Object.values(NETWORK_TOPO_CODES)) {
expect(value).toMatch(/^NET_TOPO\./);
}
});
it("is frozen", () => {
expect(Object.isFrozen(NETWORK_TOPO_CODES)).toBe(true);
});
it("contains the documented codes", () => {
for (const key of [
"INVALID_TIER",
"PEER_NOT_FOUND",
"ALREADY_AT_TIER",
] as const) {
expect(NETWORK_TOPO_CODES).toHaveProperty(key);
}
});
});
describe("NetworkTopoError", () => {
it("extends SuaveplanError", () => {
const err = new NetworkTopoError(
NETWORK_TOPO_CODES.INVALID_TIER,
"topology.invalid-tier"
);
expect(err.name).toBe("NetworkTopoError");
expect(err.code).toBe(NETWORK_TOPO_CODES.INVALID_TIER);
});
it("preserves cause", () => {
const cause = new Error("inner");
const err = new NetworkTopoError(
NETWORK_TOPO_CODES.PEER_NOT_FOUND,
"topology.peer-not-found",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,21 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const NETWORK_TOPO_CODES = defineCodes("NET_TOPO", [
"INVALID_TIER",
"PEER_NOT_FOUND",
"ALREADY_AT_TIER",
] as const);
export type NetworkTopoErrorCode =
(typeof NETWORK_TOPO_CODES)[keyof typeof NETWORK_TOPO_CODES];
export class NetworkTopoError extends SuaveplanError<NetworkTopoErrorCode> {
public constructor(
code: NetworkTopoErrorCode,
messageKey: string,
options?: import("@suaveplan/error").SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetworkTopoError";
}
}

View File

@@ -0,0 +1,78 @@
# GroupManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Tracks group membership across the mesh. Each group has an operator-supplied capacity — there is no built-in default group size. A peer can belong to many groups simultaneously, and the manager publishes `group:membership` on every join / leave so downstream observers (the topology plugin, observability dashboards) can react.
## Features
- `defineGroup(groupId, capacity)` is the only way to create a group; capacity must be a positive integer or the call throws.
- `join(groupId, peerId)` rejects with `group.full` when the group has reached its capacity, and is a no-op (no event) for already-joined peers so callers can re-join idempotently on heartbeats.
- `leave(groupId, peerId)` returns `boolean` — `true` if the peer was actually removed, `false` for unknown groups or peers.
- `membersOf` / `groupsOf` / `list` / `getCapacity` / `size` for observability.
- `removeGroup(groupId)` fires a `leave` event for every member before deleting the group, so dashboards see a clean teardown.
- `clear()` for full reset (e.g. mesh restart).
## Usage: Basic Example
```ts
import { GroupManager } from "@suaveplan/network-plugin-topology";
const groups = new GroupManager({ bus });
groups.defineGroup("region-A", 32);
groups.join("region-A", "alice");
groups.join("region-A", "bob");
const members = groups.membersOf("region-A");
```
## Usage: Advanced Example
```ts
import {
GroupManager,
NETWORK_TOPO_CODES,
NetworkTopoError,
} from "@suaveplan/network-plugin-topology";
const groups = new GroupManager({ bus });
groups.defineGroup("hosts", 4);
groups.defineGroup("subhosts", 16);
bus.on("election:result", ({ winnerId }) => {
try {
groups.join("hosts", winnerId);
} catch (err) {
if (
err instanceof NetworkTopoError &&
err.code === NETWORK_TOPO_CODES.INVALID_TIER
) {
// group full → demote the longest-tenured host first
const oldest = groups.membersOf("hosts")[0];
if (oldest !== undefined) {
groups.leave("hosts", oldest);
groups.join("hosts", winnerId);
}
}
}
});
```
## API Reference
- `interface Group { groupId, capacity, members }`.
- `interface GroupBus { emit("group:membership", { groupId, peerId, change }) }`.
- `interface GroupManagerConfig { bus }`.
- `defineGroup(groupId, capacity)` — capacity must be `> 0`.
- `removeGroup(groupId)` — emits `leave` for every member before deletion.
- `join(groupId, peerId)` — throws `group.full` on capacity breach; no-op on duplicate.
- `leave(groupId, peerId): boolean`.
- `membersOf(groupId): readonly string[]` / `groupsOf(peerId): readonly string[]` / `list(): readonly Group[]`.
- `getCapacity(groupId): number | undefined` / `size(groupId): number`.
- `clear()`.
## Implementation Notes
We deliberately reuse `NETWORK_TOPO_CODES.INVALID_TIER` for both invalid-capacity and group-full failures rather than introducing dedicated codes — both are operator-visible misconfigurations and the small surface doesn't justify a separate registry. The `defineGroup` upsert path lets operators resize a group at runtime by calling `defineGroup` again with a new capacity; existing members are not evicted even if the new capacity is smaller, so the manager temporarily reports `size > capacity` until peers leave naturally. This is the right shape for elastic deployments where shrinking a group should be a soft signal, not a hard eviction.

View File

@@ -0,0 +1,160 @@
import { NETWORK_TOPO_CODES, NetworkTopoError } from "../error-codes.js";
/**
* One group definition.
*/
export interface Group {
readonly groupId: string;
readonly capacity: number;
readonly members: ReadonlySet<string>;
}
/**
* Bus shape for membership events.
*/
export interface GroupBus {
emit(
event: "group:membership",
payload: {
groupId: string;
peerId: string;
change: "join" | "leave";
}
): void;
}
/**
* Configuration for `GroupManager`. The constructor takes an empty
* manager — operators register groups individually via
* `defineGroup(groupId, capacity)` so capacity is always operator-supplied.
*/
export interface GroupManagerConfig {
readonly bus: GroupBus;
}
/**
* Tracks group membership across the mesh. Groups are independently
* defined with operator-supplied capacities — there is no "default
* group size". Joining a full group raises `INVALID_TIER` (we reuse
* the existing topology code namespace rather than introducing a
* separate one for this small surface).
*/
export class GroupManager {
private readonly bus: GroupBus;
private readonly groups = new Map<
string,
{ capacity: number; members: Set<string> }
>();
public constructor(config: GroupManagerConfig) {
this.bus = config.bus;
}
public defineGroup(groupId: string, capacity: number): void {
if (!Number.isInteger(capacity) || capacity <= 0) {
throw new NetworkTopoError(
NETWORK_TOPO_CODES.INVALID_TIER,
"group.invalid-capacity",
{ context: { groupId, capacity } }
);
}
const existing = this.groups.get(groupId);
if (existing === undefined) {
this.groups.set(groupId, {
capacity,
members: new Set(),
});
return;
}
existing.capacity = capacity;
}
public removeGroup(groupId: string): void {
const group = this.groups.get(groupId);
if (group === undefined) return;
for (const peerId of group.members) {
this.bus.emit("group:membership", {
groupId,
peerId,
change: "leave",
});
}
this.groups.delete(groupId);
}
public join(groupId: string, peerId: string): void {
const group = this.groups.get(groupId);
if (group === undefined) {
throw new NetworkTopoError(
NETWORK_TOPO_CODES.PEER_NOT_FOUND,
"group.not-found",
{ context: { groupId } }
);
}
if (group.members.has(peerId)) return;
if (group.members.size >= group.capacity) {
throw new NetworkTopoError(
NETWORK_TOPO_CODES.INVALID_TIER,
"group.full",
{
context: {
groupId,
peerId,
capacity: group.capacity,
},
}
);
}
group.members.add(peerId);
this.bus.emit("group:membership", {
groupId,
peerId,
change: "join",
});
}
public leave(groupId: string, peerId: string): boolean {
const group = this.groups.get(groupId);
if (group === undefined) return false;
if (!group.members.delete(peerId)) return false;
this.bus.emit("group:membership", {
groupId,
peerId,
change: "leave",
});
return true;
}
public membersOf(groupId: string): readonly string[] {
const group = this.groups.get(groupId);
return group === undefined ? [] : [...group.members];
}
public groupsOf(peerId: string): readonly string[] {
const out: string[] = [];
for (const [groupId, group] of this.groups) {
if (group.members.has(peerId)) out.push(groupId);
}
return out;
}
public list(): readonly Group[] {
return [...this.groups.entries()].map(([groupId, group]) => ({
groupId,
capacity: group.capacity,
members: new Set(group.members),
}));
}
public getCapacity(groupId: string): number | undefined {
return this.groups.get(groupId)?.capacity;
}
public size(groupId: string): number {
return this.groups.get(groupId)?.members.size ?? 0;
}
public clear(): void {
this.groups.clear();
}
}

View File

@@ -0,0 +1,156 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { type GroupBus, GroupManager } from "./group-manager.js";
function makeBus(): GroupBus & {
events: Array<{
groupId: string;
peerId: string;
change: "join" | "leave";
}>;
} {
const events: Array<{
groupId: string;
peerId: string;
change: "join" | "leave";
}> = [];
return {
events,
emit(_e, payload): void {
events.push(payload);
},
};
}
describe("GroupManager", () => {
it("defines groups and tracks capacity", () => {
const m = new GroupManager({ bus: makeBus() });
m.defineGroup("hosts", 4);
expect(m.getCapacity("hosts")).toBe(4);
expect(m.size("hosts")).toBe(0);
});
it("rejects invalid capacities", () => {
const m = new GroupManager({ bus: makeBus() });
expect(() => m.defineGroup("bad", 0)).toThrow("group.invalid-capacity");
expect(() => m.defineGroup("bad", -1)).toThrow(
"group.invalid-capacity"
);
expect(() => m.defineGroup("bad", 1.5)).toThrow(
"group.invalid-capacity"
);
});
it("upserts capacity on duplicate defineGroup", () => {
const m = new GroupManager({ bus: makeBus() });
m.defineGroup("hosts", 4);
m.defineGroup("hosts", 8);
expect(m.getCapacity("hosts")).toBe(8);
});
it("join adds members and emits", () => {
const bus = makeBus();
const m = new GroupManager({ bus });
m.defineGroup("hosts", 2);
m.join("hosts", "alice");
expect(m.membersOf("hosts")).toEqual(["alice"]);
expect(bus.events.at(-1)).toEqual({
groupId: "hosts",
peerId: "alice",
change: "join",
});
});
it("re-join is a no-op", () => {
const bus = makeBus();
const m = new GroupManager({ bus });
m.defineGroup("hosts", 2);
m.join("hosts", "alice");
m.join("hosts", "alice");
expect(bus.events).toHaveLength(1);
});
it("rejects join on unknown group", () => {
const m = new GroupManager({ bus: makeBus() });
expect(() => m.join("ghost", "alice")).toThrow("group.not-found");
});
it("rejects join when group is full", () => {
const m = new GroupManager({ bus: makeBus() });
m.defineGroup("hosts", 1);
m.join("hosts", "alice");
expect(() => m.join("hosts", "bob")).toThrow("group.full");
});
it("leave removes a member and reports success", () => {
const bus = makeBus();
const m = new GroupManager({ bus });
m.defineGroup("hosts", 2);
m.join("hosts", "alice");
expect(m.leave("hosts", "alice")).toBe(true);
expect(m.leave("hosts", "alice")).toBe(false);
expect(m.leave("ghost", "alice")).toBe(false);
});
it("groupsOf reports every group a peer belongs to", () => {
const m = new GroupManager({ bus: makeBus() });
m.defineGroup("hosts", 4);
m.defineGroup("subhosts", 4);
m.join("hosts", "alice");
m.join("subhosts", "alice");
expect(m.groupsOf("alice").sort()).toEqual(["hosts", "subhosts"]);
});
it("list returns every group with member snapshots", () => {
const m = new GroupManager({ bus: makeBus() });
m.defineGroup("hosts", 4);
m.join("hosts", "alice");
const all = m.list();
expect(all).toHaveLength(1);
expect(all[0]?.members.has("alice")).toBe(true);
});
it("removeGroup emits leave for every member", () => {
const bus = makeBus();
const m = new GroupManager({ bus });
m.defineGroup("hosts", 4);
m.join("hosts", "alice");
m.join("hosts", "bob");
const before = bus.events.length;
m.removeGroup("hosts");
const leaveEvents = bus.events
.slice(before)
.filter((e) => e.change === "leave");
expect(leaveEvents).toHaveLength(2);
expect(m.getCapacity("hosts")).toBeUndefined();
});
it("removeGroup on unknown group is a no-op", () => {
const m = new GroupManager({ bus: makeBus() });
expect(() => m.removeGroup("ghost")).not.toThrow();
});
it("clear empties everything", () => {
const m = new GroupManager({ bus: makeBus() });
m.defineGroup("hosts", 4);
m.join("hosts", "alice");
m.clear();
expect(m.list()).toEqual([]);
});
it("returns empty list / undefined size for unknown groups", () => {
const m = new GroupManager({ bus: makeBus() });
expect(m.membersOf("ghost")).toEqual([]);
expect(m.size("ghost")).toBe(0);
});
it("groupsOf skips groups the peer is not in", () => {
const m = new GroupManager({ bus: makeBus() });
m.defineGroup("hosts", 4);
m.defineGroup("subhosts", 4);
m.defineGroup("leaves", 4);
m.join("hosts", "alice");
m.join("leaves", "bob");
// alice is only in hosts — subhosts and leaves both must be skipped
expect(m.groupsOf("alice")).toEqual(["hosts"]);
});
});

View File

@@ -0,0 +1,36 @@
// Error codes (side-effect registration)
import "./error-codes.js";
// Errors
export {
NETWORK_TOPO_CODES,
NetworkTopoError,
type NetworkTopoErrorCode,
} from "./error-codes.js";
// Group manager
export {
type Group,
type GroupBus,
GroupManager,
type GroupManagerConfig,
} from "./group/group-manager.js";
// Latency assigner
export {
LatencyAssigner,
type LatencyAssignerConfig,
type LatencyBucket,
type LatencyBus,
} from "./latency/latency-assigner.js";
// Plugin
export {
TopologyPlugin,
type TopologyPluginConfig,
} from "./plugin/topology-plugin.js";
// Topology
export {
HierarchicalTopology,
type HierarchicalTopologyConfig,
type Tier,
type TopologyBus,
type TopologyNode,
} from "./topology/hierarchical-topology.js";

View File

@@ -0,0 +1,75 @@
# LatencyAssigner
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Assigns peers to a latency bucket given their measured RTT and publishes `latency:rebalance` whenever a peer transitions between buckets. Bucket boundaries, names, and the overflow label are all operator-supplied — the assigner makes no assumption about what "fast" or "slow" means for any particular deployment.
## Features
- Operator-supplied bucket list with explicit ascending validation; misconfigured buckets throw at construction time.
- Operator-supplied `overflowLabel` for latencies above every bucket; the assigner never defaults to a name on the operator's behalf.
- `record(peerId, latencyMs)` returns the assigned label and emits `latency:rebalance` only on bucket changes (no churn for jitter inside one bucket).
- `bucketFor(latencyMs)` is a pure classifier — no side effects, useful for "what-if" scoring in `network-plugin-assignment`.
- `getBucket` / `listInBucket` / `bucketLabels` / `forget` / `clear` for observability and lifecycle.
## Usage: Basic Example
```ts
import { LatencyAssigner } from "@suaveplan/network-plugin-topology";
const assigner = new LatencyAssigner({
bus,
buckets: [
{ label: "local", maxLatencyMs: 30 },
{ label: "regional", maxLatencyMs: 100 },
{ label: "continental", maxLatencyMs: 250 },
],
overflowLabel: "global",
});
assigner.record("alice", 12); // → "local"
assigner.record("alice", 80); // → "regional", fires latency:rebalance
```
## Usage: Advanced Example
```ts
import { LatencyAssigner } from "@suaveplan/network-plugin-topology";
const assigner = new LatencyAssigner({
bus,
buckets: [
{ label: "rack", maxLatencyMs: 1 },
{ label: "row", maxLatencyMs: 5 },
{ label: "dc", maxLatencyMs: 25 },
{ label: "region", maxLatencyMs: 80 },
],
overflowLabel: "wan",
});
bus.on("peer:latency", ({ peerId, rttMs }) => {
assigner.record(peerId, rttMs);
});
const wanPeers = assigner.listInBucket("wan");
metrics.gauge("topology.wan_peers", wanPeers.length);
```
## API Reference
- `interface LatencyBucket { label, maxLatencyMs }`.
- `interface LatencyBus { emit("latency:rebalance", { peerId, from, to, latencyMs }) }`.
- `interface LatencyAssignerConfig { bus, buckets, overflowLabel }` — every field required.
- `bucketFor(latencyMs): string` — pure classifier.
- `record(peerId, latencyMs): string` — gated emit.
- `getBucket(peerId): string | undefined`.
- `listInBucket(label): readonly string[]`.
- `bucketLabels(): readonly string[]` — every configured bucket plus the overflow label.
- `forget(peerId)` / `clear()`.
## Implementation Notes
The assigner validates ascending order at construction (rather than runtime) so a misconfigured bucket list surfaces as a constructor exception rather than as quietly-wrong classification across many records. `record` only emits on transition because measurement noise produces frequent micro-changes inside a bucket; downstream subscribers (the topology plugin, observability dashboards) want stable signals, not jitter. The overflow label is required (no default) because operators that intend "treat everything above X as one bucket" should name that bucket explicitly — silent fallback to `"unknown"` or similar would mask a configuration gap.

View File

@@ -0,0 +1,123 @@
/**
* One bucket in the latency-bucket configuration. The bucket label is
* operator-supplied so callers can use any classification taxonomy
* (`"local" | "regional" | "global"`, geographic codes, RTT-bin names,
* anything). The bucket matches when `latencyMs <= maxLatencyMs`.
*/
export interface LatencyBucket {
readonly label: string;
readonly maxLatencyMs: number;
}
/**
* Bus shape — `LatencyAssigner` publishes `latency:rebalance` whenever
* a peer's bucket changes.
*/
export interface LatencyBus {
emit(
event: "latency:rebalance",
payload: {
peerId: string;
from: string | null;
to: string;
latencyMs: number;
}
): void;
}
/**
* Configuration for `LatencyAssigner`. Buckets are required and fully
* operator-supplied — there are no built-in latency thresholds.
*
* - `buckets` MUST be non-empty and sorted ascending by `maxLatencyMs`.
* - `overflowLabel` MUST be supplied so latencies above the highest
* bucket's threshold have a deterministic destination; the assigner
* never picks a default name on the operator's behalf.
*/
export interface LatencyAssignerConfig {
readonly bus: LatencyBus;
readonly buckets: readonly LatencyBucket[];
readonly overflowLabel: string;
}
/**
* Assigns peers to a latency bucket given their measured RTT. The
* bucket boundaries, names, and overflow label are all operator-supplied
* — the assigner has no opinion about what "fast" or "slow" means for
* any particular deployment.
*/
export class LatencyAssigner {
private readonly bus: LatencyBus;
private readonly buckets: readonly LatencyBucket[];
private readonly overflowLabel: string;
private readonly current = new Map<string, string>();
public constructor(config: LatencyAssignerConfig) {
if (config.buckets.length === 0) {
throw new Error(
"LatencyAssigner: buckets must contain at least one entry"
);
}
// Validate ascending order so consumers see misconfiguration
// immediately rather than as silent misclassification.
for (let i = 1; i < config.buckets.length; i++) {
if (
config.buckets[i]!.maxLatencyMs <
config.buckets[i - 1]!.maxLatencyMs
) {
throw new Error(
"LatencyAssigner: buckets must be sorted ascending by maxLatencyMs"
);
}
}
this.bus = config.bus;
this.buckets = config.buckets;
this.overflowLabel = config.overflowLabel;
}
public bucketFor(latencyMs: number): string {
for (const b of this.buckets) {
if (latencyMs <= b.maxLatencyMs) return b.label;
}
return this.overflowLabel;
}
public record(peerId: string, latencyMs: number): string {
const label = this.bucketFor(latencyMs);
const previous = this.current.get(peerId) ?? null;
if (previous !== label) {
this.current.set(peerId, label);
this.bus.emit("latency:rebalance", {
peerId,
from: previous,
to: label,
latencyMs,
});
}
return label;
}
public getBucket(peerId: string): string | undefined {
return this.current.get(peerId);
}
public listInBucket(label: string): readonly string[] {
const out: string[] = [];
for (const [peerId, l] of this.current) {
if (l === label) out.push(peerId);
}
return out;
}
public bucketLabels(): readonly string[] {
return [...this.buckets.map((b) => b.label), this.overflowLabel];
}
public forget(peerId: string): void {
this.current.delete(peerId);
}
public clear(): void {
this.current.clear();
}
}

View File

@@ -0,0 +1,152 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
LatencyAssigner,
type LatencyBucket,
type LatencyBus,
} from "./latency-assigner.js";
function makeBus(): LatencyBus & {
events: Array<{
peerId: string;
from: string | null;
to: string;
latencyMs: number;
}>;
} {
const events: Array<{
peerId: string;
from: string | null;
to: string;
latencyMs: number;
}> = [];
return {
events,
emit(_e, payload): void {
events.push(payload);
},
};
}
const buckets: LatencyBucket[] = [
{ label: "local", maxLatencyMs: 30 },
{ label: "regional", maxLatencyMs: 100 },
{ label: "continental", maxLatencyMs: 250 },
];
describe("LatencyAssigner", () => {
it("classifies into the first matching bucket", () => {
const a = new LatencyAssigner({
bus: makeBus(),
buckets,
overflowLabel: "global",
});
expect(a.bucketFor(10)).toBe("local");
expect(a.bucketFor(50)).toBe("regional");
expect(a.bucketFor(200)).toBe("continental");
expect(a.bucketFor(500)).toBe("global");
});
it("emits latency:rebalance only on bucket change", () => {
const bus = makeBus();
const a = new LatencyAssigner({
bus,
buckets,
overflowLabel: "global",
});
expect(a.record("alice", 10)).toBe("local");
expect(a.record("alice", 12)).toBe("local");
expect(bus.events).toHaveLength(1);
expect(bus.events[0]?.from).toBeNull();
expect(bus.events[0]?.to).toBe("local");
expect(a.record("alice", 80)).toBe("regional");
expect(bus.events).toHaveLength(2);
expect(bus.events[1]?.from).toBe("local");
});
it("getBucket returns the last assigned label or undefined", () => {
const a = new LatencyAssigner({
bus: makeBus(),
buckets,
overflowLabel: "global",
});
expect(a.getBucket("ghost")).toBeUndefined();
a.record("alice", 10);
expect(a.getBucket("alice")).toBe("local");
});
it("listInBucket filters by label", () => {
const a = new LatencyAssigner({
bus: makeBus(),
buckets,
overflowLabel: "global",
});
a.record("alice", 10);
a.record("bob", 12);
a.record("carol", 80);
expect(a.listInBucket("local").sort()).toEqual(["alice", "bob"]);
expect(a.listInBucket("regional")).toEqual(["carol"]);
expect(a.listInBucket("global")).toEqual([]);
});
it("bucketLabels returns every label including overflow", () => {
const a = new LatencyAssigner({
bus: makeBus(),
buckets,
overflowLabel: "global",
});
expect(a.bucketLabels()).toEqual([
"local",
"regional",
"continental",
"global",
]);
});
it("forget drops one peer", () => {
const a = new LatencyAssigner({
bus: makeBus(),
buckets,
overflowLabel: "global",
});
a.record("alice", 10);
a.forget("alice");
expect(a.getBucket("alice")).toBeUndefined();
});
it("clear empties everything", () => {
const a = new LatencyAssigner({
bus: makeBus(),
buckets,
overflowLabel: "global",
});
a.record("alice", 10);
a.record("bob", 80);
a.clear();
expect(a.listInBucket("local")).toEqual([]);
});
it("rejects empty buckets at construction", () => {
expect(
() =>
new LatencyAssigner({
bus: makeBus(),
buckets: [],
overflowLabel: "global",
})
).toThrow();
});
it("rejects unsorted buckets at construction", () => {
expect(
() =>
new LatencyAssigner({
bus: makeBus(),
buckets: [
{ label: "high", maxLatencyMs: 100 },
{ label: "low", maxLatencyMs: 10 },
],
overflowLabel: "global",
})
).toThrow();
});
});

View File

@@ -0,0 +1,75 @@
# TopologyPlugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`NetworkPlugin` that owns the topology, latency-assigner, and group manager and wires them to the node's bus. Subscribes to `election:result` and `peer:latency` so upstream signals automatically drive topology promotions and bucket reassignments — the consuming application does not have to write any glue between the election engine and the topology.
## Features
- All three sub-managers are fully operator-configurable; if `latency` config is omitted, the plugin runs with no latency bucketing (some deployments don't need it).
- `topology.bus` / `latency.bus` / `group.bus` are filled in automatically from the host node's `networkBus` so config doesn't have to repeat the bus three times.
- Election winners auto-register at tier 0 (or are promoted to it) — operators can opt out by not subscribing to `election:result` (the plugin still installs the listener; consumers that need a different policy should configure their own bus topic).
- `peer:latency` events automatically feed `LatencyAssigner.record`.
- `dispose()` clears every sub-manager and nulls them out so a re-install starts fresh.
## Usage: Basic Example
```ts
import { TopologyPlugin } from "@suaveplan/network-plugin-topology";
const plugin = new TopologyPlugin({
topology: { maxDepth: 3 },
latency: {
buckets: [
{ label: "local", maxLatencyMs: 30 },
{ label: "regional", maxLatencyMs: 100 },
],
overflowLabel: "global",
},
});
node.use(plugin);
await node.start();
```
## Usage: Advanced Example
```ts
import { TopologyPlugin } from "@suaveplan/network-plugin-topology";
const plugin = new TopologyPlugin({
topology: {
maxDepth: 5,
tierLabels: ["RootHost", "Region", "Cluster", "Group", "Leaf"],
},
latency: {
buckets: [
{ label: "rack", maxLatencyMs: 1 },
{ label: "row", maxLatencyMs: 5 },
{ label: "dc", maxLatencyMs: 25 },
{ label: "region", maxLatencyMs: 80 },
],
overflowLabel: "wan",
},
});
node.use(plugin);
await node.start();
// downstream subsystems can manipulate the managers directly
plugin.group?.defineGroup("hosts", 4);
plugin.topology?.register("alice", 4);
```
## API Reference
- `interface TopologyPluginConfig { topology?, latency?, group? }` — every sub-section optional; bus fields are auto-filled.
- Public properties: `topology: HierarchicalTopology | null`, `latency: LatencyAssigner | null`, `group: GroupManager | null` (all populated after `install`).
- `name: "topology"`.
- `install(node)` / `start()` / `stop()` / `dispose()` — standard `NetworkPlugin` lifecycle.
## Implementation Notes
The bus type is widened with `as never` because `NetworkBus` is generically typed and the plugin's topics (`topology:change`, `latency:rebalance`, `group:membership`, `election:result`, `peer:latency`) are not in its compile-time map. Sub-managers are typed nullable because they only exist after `install` runs — callers that need them before install must construct a fresh plugin instance. The two listeners are intentionally defensive: a malformed payload is silently dropped rather than throwing, because bus traffic from other plugins should not be able to crash the topology plugin via type confusion.

View File

@@ -0,0 +1,177 @@
import { NetworkNode } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TopologyPlugin } from "./topology-plugin.js";
describe("TopologyPlugin", () => {
it("exposes the canonical name", () => {
const p = new TopologyPlugin();
expect(p.name).toBe("topology");
});
it("install builds topology and group managers (latency optional)", () => {
const node = new NetworkNode();
const plugin = new TopologyPlugin();
node.use(plugin);
expect(plugin.topology).not.toBeNull();
expect(plugin.group).not.toBeNull();
expect(plugin.latency).toBeNull();
});
it("install builds latency assigner when configured", () => {
const node = new NetworkNode();
const plugin = new TopologyPlugin({
latency: {
buckets: [{ label: "fast", maxLatencyMs: 50 }],
overflowLabel: "slow",
},
});
node.use(plugin);
expect(plugin.latency).not.toBeNull();
});
it("election:result registers a new winner at tier 0", async () => {
const node = new NetworkNode();
const plugin = new TopologyPlugin({
topology: { maxDepth: 3 },
});
node.use(plugin);
await node.start();
node.networkBus.emit("election:result", {
mode: "metric",
winnerId: "alice",
score: 1,
term: 1,
});
expect(plugin.topology?.getTier("alice")).toBe(0);
await node.stop();
});
it("election:result promotes existing peer to tier 0", async () => {
const node = new NetworkNode();
const plugin = new TopologyPlugin({
topology: { maxDepth: 3 },
});
node.use(plugin);
await node.start();
plugin.topology?.register("alice", 2);
node.networkBus.emit("election:result", {
mode: "metric",
winnerId: "alice",
score: 1,
term: 2,
});
expect(plugin.topology?.getTier("alice")).toBe(0);
await node.stop();
});
it("election:result is a no-op when winner is already at tier 0", async () => {
const node = new NetworkNode();
const plugin = new TopologyPlugin();
node.use(plugin);
await node.start();
plugin.topology?.register("alice", 0);
node.networkBus.emit("election:result", {
mode: "metric",
winnerId: "alice",
score: 1,
term: 3,
});
expect(plugin.topology?.getTier("alice")).toBe(0);
await node.stop();
});
it("malformed election:result payloads are silently dropped", async () => {
const node = new NetworkNode();
const plugin = new TopologyPlugin();
node.use(plugin);
await node.start();
node.networkBus.emit("election:result", null);
node.networkBus.emit("election:result", { winnerId: "x" }); // missing term
node.networkBus.emit("election:result", "not-an-object");
expect(plugin.topology?.size()).toBe(0);
await node.stop();
});
it("peer:latency feeds the latency assigner", async () => {
const node = new NetworkNode();
const plugin = new TopologyPlugin({
latency: {
buckets: [{ label: "fast", maxLatencyMs: 50 }],
overflowLabel: "slow",
},
});
node.use(plugin);
await node.start();
node.networkBus.emit("peer:latency", {
peerId: "alice",
rttMs: 10,
});
expect(plugin.latency?.getBucket("alice")).toBe("fast");
await node.stop();
});
it("malformed peer:latency payloads are silently dropped", async () => {
const node = new NetworkNode();
const plugin = new TopologyPlugin({
latency: {
buckets: [{ label: "fast", maxLatencyMs: 50 }],
overflowLabel: "slow",
},
});
node.use(plugin);
await node.start();
node.networkBus.emit("peer:latency", null);
node.networkBus.emit("peer:latency", { peerId: "x" }); // missing rttMs
expect(plugin.latency?.getBucket("x")).toBeUndefined();
await node.stop();
});
it("dispose clears every sub-manager", async () => {
const node = new NetworkNode();
const plugin = new TopologyPlugin({
latency: {
buckets: [{ label: "fast", maxLatencyMs: 50 }],
overflowLabel: "slow",
},
});
node.use(plugin);
await node.start();
plugin.topology?.register("alice", 0);
plugin.dispose();
expect(plugin.topology).toBeNull();
expect(plugin.latency).toBeNull();
expect(plugin.group).toBeNull();
});
it("stop unsubscribes bus listeners", async () => {
const node = new NetworkNode();
const plugin = new TopologyPlugin();
node.use(plugin);
await node.start();
await node.stop();
// After stop, bus events should not register peers
node.networkBus.emit("election:result", {
mode: "metric",
winnerId: "ghost",
score: 1,
term: 99,
});
expect(plugin.topology?.getTier("ghost")).toBeUndefined();
});
it("election:result is a no-op after dispose", () => {
// dispose without stop — listeners are still wired but topology
// is null, so the early-return guard fires.
const node = new NetworkNode();
const plugin = new TopologyPlugin();
node.use(plugin);
plugin.dispose();
node.networkBus.emit("election:result", {
mode: "metric",
winnerId: "alice",
score: 1,
term: 1,
});
expect(plugin.topology).toBeNull();
});
});

View File

@@ -0,0 +1,123 @@
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
import {
GroupManager,
type GroupManagerConfig,
} from "../group/group-manager.js";
import {
LatencyAssigner,
type LatencyAssignerConfig,
} from "../latency/latency-assigner.js";
import {
HierarchicalTopology,
type HierarchicalTopologyConfig,
} from "../topology/hierarchical-topology.js";
/**
* Configuration for `TopologyPlugin`.
*
* Every sub-component is constructed from operator-supplied config —
* the plugin neither invents bucket boundaries nor imposes a default
* tier count. `topology.bus` / `latency.bus` / `group.bus` are filled
* in by the plugin from the host node's `networkBus` at install time
* so callers do not need to supply them three times.
*/
export interface TopologyPluginConfig {
readonly topology?: Omit<HierarchicalTopologyConfig, "bus">;
readonly latency?: Omit<LatencyAssignerConfig, "bus">;
readonly group?: Omit<GroupManagerConfig, "bus">;
}
/**
* `NetworkPlugin` that owns the topology / latency / group managers
* and wires them to the node's bus. The plugin subscribes to
* `election:result` and `peer:latency` so upstream signals
* automatically drive topology promotions and bucket reassignments
* without bespoke glue at the call site.
*/
export class TopologyPlugin implements NetworkPlugin {
public readonly name = "topology";
public topology: HierarchicalTopology | null = null;
public latency: LatencyAssigner | null = null;
public group: GroupManager | null = null;
private readonly config: TopologyPluginConfig;
private readonly unsubscribes: Array<() => void> = [];
public constructor(config: TopologyPluginConfig = {}) {
this.config = config;
}
public install(node: NetworkNode): void {
const bus = node.networkBus as never;
this.topology = new HierarchicalTopology({
...(this.config.topology ?? {}),
bus,
});
if (this.config.latency !== undefined) {
this.latency = new LatencyAssigner({
...this.config.latency,
bus,
});
}
this.group = new GroupManager({
...(this.config.group ?? {}),
bus,
});
this.unsubscribes.push(
node.networkBus.on("election:result", (payload: unknown) => {
if (
payload === null ||
typeof payload !== "object" ||
!("winnerId" in payload) ||
!("term" in payload)
) {
return;
}
const { winnerId } = payload as { winnerId: string };
if (this.topology === null) return;
const current = this.topology.getTier(winnerId);
if (current === undefined) {
this.topology.register(winnerId, 0, "election:result");
return;
}
if (current === 0) return;
this.topology.promote(winnerId, 0, "election:result");
})
);
this.unsubscribes.push(
node.networkBus.on("peer:latency", (payload: unknown) => {
if (
payload === null ||
typeof payload !== "object" ||
!("peerId" in payload) ||
!("rttMs" in payload)
) {
return;
}
const { peerId, rttMs } = payload as {
peerId: string;
rttMs: number;
};
this.latency?.record(peerId, rttMs);
})
);
}
public start(): void {
// sub-managers are already wired in install
}
public stop(): void {
for (const off of this.unsubscribes) off();
this.unsubscribes.length = 0;
}
public dispose(): void {
this.topology?.clear();
this.latency?.clear();
this.group?.clear();
this.topology = null;
this.latency = null;
this.group = null;
}
}

View File

@@ -0,0 +1,71 @@
# HierarchicalTopology
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
N-tier mesh topology. Tracks each peer's current tier (a non-negative integer where `0` is the root and higher numbers are deeper), validates promote / demote moves, and publishes `topology:change` on every transition. The class has no compile-time tier ceiling and no built-in tier names — the structure is entirely operator-driven.
## Features
- Optional `maxDepth` upper bound. When supplied, tiers must be in `[0, maxDepth)`; when omitted, any non-negative integer is accepted, so the topology can grow without re-instantiation as the mesh scales.
- Optional `tierLabels` for dashboards / logging. When omitted, `tierLabel(n)` returns `"Tier<n>"` — there are no built-in labels (no implicit `"Host" / "SubHost" / "Leaf"` assumption).
- `register` / `unregister` / `getTier` / `listAtTier` / `list` / `sizeAtTier` / `size` for ordinary lifecycle.
- `observedMaxTier()` reports the deepest currently-occupied tier so the consumer can grow or shrink supporting subsystems (latency buckets, group manager) without a hard-coded depth.
- `promote(peerId, toTier)` and `demote(peerId, toTier)` enforce direction (promote must move up, demote must move down) and surface `INVALID_TIER` / `PEER_NOT_FOUND` / `ALREADY_AT_TIER` distinctly.
- Every successful state change emits `topology:change` with `from` / `to` / `reason` so downstream observers (election engine, latency assigner, group manager) can react.
## Usage: Basic Example
```ts
import { HierarchicalTopology } from "@suaveplan/network-plugin-topology";
// no maxDepth, no labels — accept any non-negative tier
const topo = new HierarchicalTopology({ bus });
topo.register("alice", 2);
topo.promote("alice", 1, "election:result");
topo.promote("alice", 0, "host-vacancy");
```
## Usage: Advanced Example
```ts
import { HierarchicalTopology } from "@suaveplan/network-plugin-topology";
// Five-tier deployment with operator-supplied labels.
const topo = new HierarchicalTopology({
bus,
maxDepth: 5,
tierLabels: ["RootHost", "Region", "Cluster", "Group", "Leaf"],
});
topo.register("alice", 4);
topo.register("bob", 3);
const deepest = topo.observedMaxTier(); // 4
const leaves = topo.listAtTier(deepest!);
for (const peerId of leaves.slice(0, 2)) {
topo.promote(peerId, deepest! - 1, "rebalance");
}
```
## API Reference
- `type Tier = number` — `0` is root.
- `interface TopologyNode { peerId, tier }`.
- `interface TopologyBus { emit("topology:change", { peerId, from, to, reason }) }`.
- `interface HierarchicalTopologyConfig { bus, maxDepth?, tierLabels? }` — both bounds and labels are optional and operator-supplied.
- `getMaxDepth(): number | undefined`.
- `tierLabel(tier): string` — returns the configured label or `"Tier<n>"`.
- `register(peerId, tier, reason?)` / `unregister(peerId)`.
- `getTier(peerId): Tier | undefined`.
- `listAtTier(tier): readonly string[]` / `list(): readonly TopologyNode[]`.
- `size(): number` / `sizeAtTier(tier): number` / `observedMaxTier(): Tier | undefined`.
- `promote(peerId, toTier, reason?)` — `toTier` must be strictly less than current tier.
- `demote(peerId, toTier, reason?)` — `toTier` must be strictly greater than current tier.
- `clear()`.
## Implementation Notes
`promote` and `demote` route through one private `move(direction)` helper so the direction-validation logic lives in exactly one place. Throwing `INVALID_TIER` for "promote that doesn't go up" is deliberate — a caller that intends a sideways move is almost certainly a bug, and silent acceptance would let the FSM drift over time. `register(peer, sameTier)` is a no-op (no event) so callers can re-register on every heartbeat without churning the bus. `unregister` does not emit a topology change either; consumers who want disconnection signals should subscribe to `peer:disconnected` on the bus directly. The `assertTier` helper splits its check into two distinct error contexts (`invalid-tier` for negative/non-integer, `tier-out-of-bounds` for over-`maxDepth`) so dashboards can tell "operator passed a bad value" apart from "mesh hit its configured ceiling".

View File

@@ -0,0 +1,212 @@
import { NETWORK_TOPO_CODES, NetworkTopoError } from "../error-codes.js";
/**
* A tier number. `0` is the root; higher numbers are deeper in the
* hierarchy. The maximum depth is configurable per topology — this
* plugin has no compile-time tier ceiling.
*/
export type Tier = number;
/**
* Per-peer topology entry.
*/
export interface TopologyNode {
readonly peerId: string;
readonly tier: Tier;
}
/**
* Bus shape for topology events.
*/
export interface TopologyBus {
emit(
event: "topology:change",
payload: {
peerId: string;
from: Tier | null;
to: Tier;
reason: string;
}
): void;
}
/**
* Configuration for `HierarchicalTopology`.
*
* Every value is operator-supplied. There are no built-in tier counts,
* tier names, or threshold defaults — the topology is fully dynamic.
*
* - `maxDepth` (optional): when present, tiers must be in `[0, maxDepth)`;
* when omitted, any non-negative integer tier is accepted.
* - `tierLabels` (optional): per-index human-readable name for
* dashboards / logs. When omitted, `tierLabel(n)` returns `"Tier<n>"`.
* The topology never branches on labels.
*/
export interface HierarchicalTopologyConfig {
readonly bus: TopologyBus;
readonly maxDepth?: number;
readonly tierLabels?: ReadonlyArray<string>;
}
/**
* N-tier mesh topology. Tiers are non-negative integers; depth is
* configurable and may be unbounded. The same primitive serves
* 2-tier server-and-clients deployments, 3-tier Host/SubHost/Leaf
* topologies, or any deeper hierarchy that grows or shrinks at
* runtime — operators control the structure entirely through
* `register` / `promote` / `demote` calls.
*/
export class HierarchicalTopology {
private readonly bus: TopologyBus;
private readonly maxDepth: number | undefined;
private readonly labels: ReadonlyArray<string> | undefined;
private readonly nodes = new Map<string, Tier>();
public constructor(config: HierarchicalTopologyConfig) {
this.bus = config.bus;
this.maxDepth = config.maxDepth;
this.labels = config.tierLabels;
}
public getMaxDepth(): number | undefined {
return this.maxDepth;
}
public tierLabel(tier: Tier): string {
this.assertTier(tier);
return this.labels?.[tier] ?? `Tier${tier}`;
}
public register(peerId: string, tier: Tier, reason = "register"): void {
this.assertTier(tier);
const existing = this.nodes.get(peerId);
if (existing === tier) return;
this.nodes.set(peerId, tier);
this.bus.emit("topology:change", {
peerId,
from: existing ?? null,
to: tier,
reason,
});
}
public unregister(peerId: string): void {
this.nodes.delete(peerId);
}
public getTier(peerId: string): Tier | undefined {
return this.nodes.get(peerId);
}
public listAtTier(tier: Tier): readonly string[] {
this.assertTier(tier);
const out: string[] = [];
for (const [peerId, t] of this.nodes) {
if (t === tier) out.push(peerId);
}
return out;
}
public list(): readonly TopologyNode[] {
return [...this.nodes.entries()].map(([peerId, tier]) => ({
peerId,
tier,
}));
}
public size(): number {
return this.nodes.size;
}
public sizeAtTier(tier: Tier): number {
this.assertTier(tier);
let n = 0;
for (const t of this.nodes.values()) {
if (t === tier) n += 1;
}
return n;
}
public observedMaxTier(): Tier | undefined {
if (this.nodes.size === 0) return undefined;
let max = 0;
for (const t of this.nodes.values()) {
if (t > max) max = t;
}
return max;
}
public promote(peerId: string, toTier: Tier, reason = "promote"): void {
this.move(peerId, toTier, reason, "up");
}
public demote(peerId: string, toTier: Tier, reason = "demote"): void {
this.move(peerId, toTier, reason, "down");
}
public clear(): void {
this.nodes.clear();
}
private move(
peerId: string,
toTier: Tier,
reason: string,
direction: "up" | "down"
): void {
this.assertTier(toTier);
const from = this.nodes.get(peerId);
if (from === undefined) {
throw new NetworkTopoError(
NETWORK_TOPO_CODES.PEER_NOT_FOUND,
"topology.peer-not-found",
{ context: { peerId } }
);
}
if (from === toTier) {
throw new NetworkTopoError(
NETWORK_TOPO_CODES.ALREADY_AT_TIER,
"topology.already-at-tier",
{ context: { peerId, tier: from } }
);
}
if (direction === "up" && toTier >= from) {
throw new NetworkTopoError(
NETWORK_TOPO_CODES.INVALID_TIER,
"topology.promote-not-up",
{ context: { peerId, from, toTier } }
);
}
if (direction === "down" && toTier <= from) {
throw new NetworkTopoError(
NETWORK_TOPO_CODES.INVALID_TIER,
"topology.demote-not-down",
{ context: { peerId, from, toTier } }
);
}
this.nodes.set(peerId, toTier);
this.bus.emit("topology:change", {
peerId,
from,
to: toTier,
reason,
});
}
private assertTier(tier: Tier): void {
if (!Number.isInteger(tier) || tier < 0) {
throw new NetworkTopoError(
NETWORK_TOPO_CODES.INVALID_TIER,
"topology.invalid-tier",
{ context: { tier } }
);
}
if (this.maxDepth !== undefined && tier >= this.maxDepth) {
throw new NetworkTopoError(
NETWORK_TOPO_CODES.INVALID_TIER,
"topology.tier-out-of-bounds",
{ context: { tier, maxDepth: this.maxDepth } }
);
}
}
}

View File

@@ -0,0 +1,203 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
HierarchicalTopology,
type Tier,
type TopologyBus,
} from "./hierarchical-topology.js";
function makeBus(): TopologyBus & {
events: Array<{
peerId: string;
from: Tier | null;
to: Tier;
reason: string;
}>;
} {
const events: Array<{
peerId: string;
from: Tier | null;
to: Tier;
reason: string;
}> = [];
return {
events,
emit(_e, payload): void {
events.push(payload);
},
};
}
describe("HierarchicalTopology", () => {
it("starts empty and reports unknown tier", () => {
const t = new HierarchicalTopology({ bus: makeBus() });
expect(t.size()).toBe(0);
expect(t.getTier("ghost")).toBeUndefined();
expect(t.observedMaxTier()).toBeUndefined();
});
it("registers a peer and emits topology:change", () => {
const bus = makeBus();
const t = new HierarchicalTopology({ bus });
t.register("alice", 2);
expect(t.getTier("alice")).toBe(2);
expect(bus.events).toHaveLength(1);
expect(bus.events[0]?.from).toBeNull();
expect(bus.events[0]?.to).toBe(2);
});
it("re-register at the same tier is a no-op", () => {
const bus = makeBus();
const t = new HierarchicalTopology({ bus });
t.register("alice", 2);
t.register("alice", 2);
expect(bus.events).toHaveLength(1);
});
it("re-register at a different tier emits a change", () => {
const bus = makeBus();
const t = new HierarchicalTopology({ bus });
t.register("alice", 2);
t.register("alice", 1, "rebalance");
expect(bus.events).toHaveLength(2);
expect(bus.events[1]?.from).toBe(2);
expect(bus.events[1]?.reason).toBe("rebalance");
});
it("rejects negative or non-integer tiers", () => {
const t = new HierarchicalTopology({ bus: makeBus() });
expect(() => t.register("alice", -1)).toThrow("topology.invalid-tier");
expect(() => t.register("alice", 1.5)).toThrow("topology.invalid-tier");
});
it("rejects tiers >= maxDepth when configured", () => {
const t = new HierarchicalTopology({
bus: makeBus(),
maxDepth: 3,
});
expect(() => t.register("alice", 3)).toThrow(
"topology.tier-out-of-bounds"
);
// negative path on the same assertion
expect(() => t.register("alice", -1)).toThrow("topology.invalid-tier");
});
it("accepts arbitrarily deep tiers without maxDepth", () => {
const t = new HierarchicalTopology({ bus: makeBus() });
t.register("alice", 99);
expect(t.getTier("alice")).toBe(99);
});
it("tierLabel returns operator label or fallback", () => {
const t = new HierarchicalTopology({
bus: makeBus(),
tierLabels: ["Root", "Mid"],
});
expect(t.tierLabel(0)).toBe("Root");
expect(t.tierLabel(1)).toBe("Mid");
expect(t.tierLabel(5)).toBe("Tier5");
});
it("tierLabel without configured labels uses Tier<n>", () => {
const t = new HierarchicalTopology({ bus: makeBus() });
expect(t.tierLabel(0)).toBe("Tier0");
expect(t.tierLabel(7)).toBe("Tier7");
});
it("listAtTier and sizeAtTier filter by tier", () => {
const t = new HierarchicalTopology({ bus: makeBus() });
t.register("alice", 0);
t.register("bob", 1);
t.register("carol", 1);
expect(t.listAtTier(0)).toEqual(["alice"]);
expect(t.sizeAtTier(1)).toBe(2);
expect(t.listAtTier(2)).toEqual([]);
});
it("list returns every node", () => {
const t = new HierarchicalTopology({ bus: makeBus() });
t.register("alice", 0);
t.register("bob", 2);
const all = t.list().sort((a, b) => a.peerId.localeCompare(b.peerId));
expect(all).toEqual([
{ peerId: "alice", tier: 0 },
{ peerId: "bob", tier: 2 },
]);
});
it("observedMaxTier returns the deepest occupied tier", () => {
const t = new HierarchicalTopology({ bus: makeBus() });
t.register("alice", 0);
t.register("bob", 5);
t.register("carol", 3);
expect(t.observedMaxTier()).toBe(5);
});
it("promote moves up and emits", () => {
const bus = makeBus();
const t = new HierarchicalTopology({ bus });
t.register("alice", 2);
t.promote("alice", 1, "election");
expect(t.getTier("alice")).toBe(1);
expect(bus.events.at(-1)?.reason).toBe("election");
});
it("demote moves down", () => {
const t = new HierarchicalTopology({ bus: makeBus() });
t.register("alice", 0);
t.demote("alice", 1, "rebalance");
expect(t.getTier("alice")).toBe(1);
});
it("promote rejects when not actually moving up", () => {
const t = new HierarchicalTopology({ bus: makeBus() });
t.register("alice", 1);
expect(() => t.promote("alice", 2)).toThrow("topology.promote-not-up");
});
it("demote rejects when not actually moving down", () => {
const t = new HierarchicalTopology({ bus: makeBus() });
t.register("alice", 1);
expect(() => t.demote("alice", 0)).toThrow("topology.demote-not-down");
});
it("promote/demote on unknown peer throws PEER_NOT_FOUND", () => {
const t = new HierarchicalTopology({ bus: makeBus() });
expect(() => t.promote("ghost", 0)).toThrow("topology.peer-not-found");
});
it("promote to current tier throws ALREADY_AT_TIER", () => {
const t = new HierarchicalTopology({ bus: makeBus() });
t.register("alice", 1);
expect(() => t.promote("alice", 1)).toThrow("topology.already-at-tier");
});
it("unregister removes the peer without emitting a change", () => {
const bus = makeBus();
const t = new HierarchicalTopology({ bus });
t.register("alice", 0);
const before = bus.events.length;
t.unregister("alice");
expect(t.getTier("alice")).toBeUndefined();
expect(bus.events.length).toBe(before);
});
it("clear empties the topology", () => {
const t = new HierarchicalTopology({ bus: makeBus() });
t.register("alice", 0);
t.register("bob", 1);
t.clear();
expect(t.size()).toBe(0);
});
it("getMaxDepth returns the configured value or undefined", () => {
expect(
new HierarchicalTopology({ bus: makeBus() }).getMaxDepth()
).toBeUndefined();
expect(
new HierarchicalTopology({
bus: makeBus(),
maxDepth: 4,
}).getMaxDepth()
).toBe(4);
});
});

View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-plugin-topology dep-graph", () => {
it("loads index.js (acyclic static imports)", async () => {
const mod = await import("../src/index.js");
expect(mod.HierarchicalTopology).toBeDefined();
expect(mod.LatencyAssigner).toBeDefined();
expect(mod.GroupManager).toBeDefined();
expect(mod.TopologyPlugin).toBeDefined();
});
});

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir:
"../../../node_modules/.vite/packages/network-plugin-topology",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/network",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});