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,32 @@
# @suaveplan/network-plugin-session - Hierarchical session management for the Suaveplan mesh: SessionTree, SessionManager, SessionRouter, per-session host election, transition manager, parent-state bridge, capacity enforcer, registry
## Summary
Hierarchical session management for the Suaveplan mesh. `SessionTree` maintains a parent-child session graph rooted at a single auto-created root; `SessionManager` handles `join` / `leave` / `move` with deterministic tree mutations and `session:joined` / `session:left` / `session:moved` bus events; `SessionRouter` filters `NetworkNode.send()` so messages reach only peers in the target session scope (default leaf, `parent`, or `broadcast`); `SessionHostElection` picks a host per session via injectable scoring (latency / uptime / reputation) and re-elects on host departure; `SessionTransitionManager` runs the join → request snapshot → sync → active state machine, with a forwarding tunnel for in-flight messages during `move`; `ParentStateBridge` replicates configurable read-only keys (worldTime / weather / globalEvent) from parent to child sessions; `SessionCapacityEnforcer` rejects over-capacity joins with optional `session:split-requested` trigger; `SessionRegistry` tracks every known session for mesh-wide discovery + assignment. Wrapped in a single `SessionPlugin` satisfying the `NetworkPlugin` lifecycle.
<!-- begin:auto-docs -->
## Documentation tree
- [bridge](./src/bridge/parent-state-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [parent-state-bridge](./src/bridge/parent-state-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [capacity](./src/capacity/session-capacity-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [session-capacity-enforcer](./src/capacity/session-capacity-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [election](./src/election/session-host-election.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [session-host-election](./src/election/session-host-election.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/session-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [session-plugin](./src/plugin/session-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [registry](./src/registry/session-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [session-registry](./src/registry/session-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [router](./src/router/session-router.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [session-router](./src/router/session-router.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [session](./src/session/session-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [session-manager](./src/session/session-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transition](./src/transition/session-transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [session-transition-manager](./src/transition/session-transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [tree](./src/tree/session-tree.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [session-tree](./src/tree/session-tree.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the session package. Codes are namespaced under `NET_SESSION` and registe…
<!-- end:auto-docs --> - [parent-state-bridge](./src/bridge/parent-state-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [capacity](./src/capacity/session-capacity-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-capacity-enforcer](./src/capacity/session-capacity-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_- [election](./src/election/session-host-election.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-host-election](./src/election/session-host-election.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/session-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-plugin](./src/plugin/session-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/session-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-registry](./src/registry/session-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [router](./src/router/session-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-router](./src/router/session-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [session](./src/session/session-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-manager](./src/session/session-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [transition](./src/transition/session-transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-transition-manager](./src/transition/session-transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [tree](./src/tree/session-tree.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-tree](./src/tree/session-tree.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the session package. Codes are namespaced under `NET_SESSION` and registe…
- [parent-state-bridge](./src/bridge/parent-state-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [capacity](./src/capacity/session-capacity-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-capacity-enforcer](./src/capacity/session-capacity-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_- [election](./src/election/session-host-election.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-host-election](./src/election/session-host-election.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/session-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-plugin](./src/plugin/session-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/session-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-registry](./src/registry/session-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [router](./src/router/session-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-router](./src/router/session-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [session](./src/session/session-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-manager](./src/session/session-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [transition](./src/transition/session-transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-transition-manager](./src/transition/session-transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [tree](./src/tree/session-tree.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-tree](./src/tree/session-tree.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the session package. Codes are namespaced under `NET_SESSION` and registe…
- [parent-state-bridge](./src/bridge/parent-state-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [capacity](./src/capacity/session-capacity-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-capacity-enforcer](./src/capacity/session-capacity-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_- [election](./src/election/session-host-election.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-host-election](./src/election/session-host-election.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/session-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-plugin](./src/plugin/session-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/session-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-registry](./src/registry/session-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [router](./src/router/session-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-router](./src/router/session-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [session](./src/session/session-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-manager](./src/session/session-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [transition](./src/transition/session-transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-transition-manager](./src/transition/session-transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [tree](./src/tree/session-tree.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-tree](./src/tree/session-tree.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the session package. Codes are namespaced under `NET_SESSION` and registe…
- [parent-state-bridge](./src/bridge/parent-state-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [capacity](./src/capacity/session-capacity-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [session-capacity-enforcer](./src/capacity/session-capacity-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_- [election](./src/election/session-host-election.md) — <!-- begin:auto-docs --> _(no submodules)_ - [session-host-election](./src/election/session-host-election.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/session-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [session-plugin](./src/plugin/session-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/session-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [session-registry](./src/registry/session-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [router](./src/router/session-router.md) — <!-- begin:auto-docs --> _(no submodules)_ - [session-router](./src/router/session-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [session](./src/session/session-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [session-manager](./src/session/session-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [transition](./src/transition/session-transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [session-transition-manager](./src/transition/session-transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [tree](./src/tree/session-tree.md) — <!-- begin:auto-docs --> _(no submodules)_ - [session-tree](./src/tree/session-tree.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the session package. Codes are namespaced under `NET_SESSION` and registe…

View File

@@ -0,0 +1,49 @@
{
"name": "@suaveplan/network-plugin-session",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Hierarchical session management for the Suaveplan mesh: SessionTree, SessionManager, SessionRouter, per-session host election, transition manager, parent-state bridge, capacity enforcer, registry",
"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,117 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SessionTree } from "../tree/session-tree.js";
import { ParentStateBridge } from "./parent-state-bridge.js";
const setup = () => {
const tree = new SessionTree();
tree.createSession({
id: "freeroam",
parentId: tree.rootId(),
rules: { capacity: 30 },
});
tree.createSession({
id: "heist",
parentId: "freeroam",
rules: { capacity: 4 },
});
return { tree };
};
describe("ParentStateBridge: read-only key replication", () => {
it("publish on parent propagates to descendants", () => {
const { tree } = setup();
const bridge = new ParentStateBridge({
tree,
bridgedKeys: ["worldTime"],
});
const updates = bridge.publish("freeroam", "worldTime", 14_400);
expect(updates.map((u) => u.sessionId)).toEqual(["heist"]);
expect(bridge.read("heist", "worldTime")).toBe(14_400);
});
it("publish on a non-bridged key is a no-op", () => {
const { tree } = setup();
const bridge = new ParentStateBridge({
tree,
bridgedKeys: ["worldTime"],
});
const updates = bridge.publish("freeroam", "private-key", 1);
expect(updates).toEqual([]);
expect(bridge.read("heist", "private-key")).toBeUndefined();
});
it("read for a non-bridged key returns undefined even if stored", () => {
const { tree } = setup();
const bridge = new ParentStateBridge({
tree,
bridgedKeys: ["worldTime"],
});
bridge.publish("freeroam", "worldTime", 1);
expect(bridge.read("freeroam", "private-key")).toBeUndefined();
});
it("snapshot returns the bridged state map for a session", () => {
const { tree } = setup();
const bridge = new ParentStateBridge({
tree,
bridgedKeys: ["worldTime", "weather"],
});
bridge.publish("freeroam", "worldTime", 14_400);
bridge.publish("freeroam", "weather", "rain");
expect(bridge.snapshot("heist")).toEqual({
worldTime: 14_400,
weather: "rain",
});
});
it("snapshot for an unknown session returns empty object", () => {
const { tree } = setup();
const bridge = new ParentStateBridge({
tree,
bridgedKeys: ["x"],
});
expect(bridge.snapshot("unknown")).toEqual({});
});
it("bridgedKeyList exposes the configured set", () => {
const { tree } = setup();
const bridge = new ParentStateBridge({
tree,
bridgedKeys: ["a", "b"],
});
expect(bridge.bridgedKeyList().sort()).toEqual(["a", "b"]);
});
it("clear() with no arg flushes every session", () => {
const { tree } = setup();
const bridge = new ParentStateBridge({
tree,
bridgedKeys: ["worldTime"],
});
bridge.publish("freeroam", "worldTime", 1);
bridge.clear();
expect(bridge.snapshot("heist")).toEqual({});
});
it("clear(sessionId) flushes only that session", () => {
const { tree } = setup();
const bridge = new ParentStateBridge({
tree,
bridgedKeys: ["worldTime"],
});
bridge.publish("freeroam", "worldTime", 1);
bridge.clear("heist");
expect(bridge.snapshot("heist")).toEqual({});
expect(bridge.read("freeroam", "worldTime")).toBe(1);
});
it("publish updates the source session itself, not just descendants", () => {
const { tree } = setup();
const bridge = new ParentStateBridge({
tree,
bridgedKeys: ["worldTime"],
});
bridge.publish("freeroam", "worldTime", 42);
expect(bridge.read("freeroam", "worldTime")).toBe(42);
});
});

View File

@@ -0,0 +1,99 @@
# `ParentStateBridge`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Replicates a curated read-only set of keys (e.g. `worldTime`, `weather`, `globalEvent`) from a parent session into every descendant so child-session peers see the surrounding world state without joining the parent's full traffic. Reads are O(1) per (`sessionId`, `key`) pair; publishes fan out across descendants.
## Features
- Construction-time `bridgedKeys` allowlist so the bridge cannot accidentally leak arbitrary parent state.
- `publish(sessionId, key, value)` updates the parent session AND every descendant so reads from any subtree node return the latest value.
- `read(sessionId, key)` returns `undefined` for non-bridged keys — protective against schema drift.
- `snapshot(sessionId)` returns the full bridged state for a session (useful for join handlers wanting to seed a newly-arrived peer).
- `clear(sessionId?)` flushes one or all sessions for tests / re-attach scenarios.
## Usage: Basic Example
```ts
import {
ParentStateBridge,
SessionTree,
} from "@suaveplan/network-plugin-session";
const tree = new SessionTree();
const bridge = new ParentStateBridge({
tree,
bridgedKeys: ["worldTime", "weather"],
});
tree.createSession({
id: "freeroam-01",
parentId: tree.rootId(),
rules: { capacity: 30 },
});
tree.createSession({
id: "heist-01",
parentId: "freeroam-01",
rules: { capacity: 4 },
});
bridge.publish("freeroam-01", "worldTime", 14_400);
bridge.read("heist-01", "worldTime"); // 14400
```
## Usage: Advanced Example
```ts
import {
ParentStateBridge,
SessionTree,
} from "@suaveplan/network-plugin-session";
const tree = new SessionTree();
tree.createSession({
id: "freeroam-01",
parentId: tree.rootId(),
rules: { capacity: 30 },
});
tree.createSession({
id: "heist-01",
parentId: "freeroam-01",
rules: { capacity: 4 },
});
tree.createSession({
id: "phase-drill",
parentId: "heist-01",
rules: { capacity: 2 },
});
const bridge = new ParentStateBridge({
tree,
bridgedKeys: ["worldTime", "weather", "globalEvent"],
});
bridge.publish("freeroam-01", "weather", "rain");
bridge.publish("heist-01", "globalEvent", "double-XP");
bridge.snapshot("phase-drill"); // { weather: "rain", globalEvent: "double-XP" }
bridge.read("phase-drill", "internal-key"); // undefined — not bridged
```
## API Reference
- `class ParentStateBridge(config: ParentStateBridgeConfig)`
- `publish(sessionId, key, value): ReadonlyArray<ParentStateUpdate>` — `[]` when key is not bridged.
- `read(sessionId, key): unknown`.
- `snapshot(sessionId): Readonly<Record<string, unknown>>`.
- `bridgedKeyList(): ReadonlyArray<string>`.
- `clear(sessionId?): void`.
- `interface ParentStateBridgeConfig` — `{ tree, bridgedKeys }`.
- `interface ParentStateUpdate` — `{ sessionId, key, value }`.
## Implementation Notes
- The bridge consults the tree's `descendantsOf` on every publish so newly-created descendants receive subsequent publishes automatically — no replay needed.
- Existing descendant values are always overwritten on publish; the bridge does not implement deltas because the typical bridged keys (time / weather / event flags) are leaf scalars.
- The bridge never propagates child → parent — that direction is exclusively the `parent` scope's job in `SessionRouter`.
- `bridgedKeys` are matched literally; structured keys (e.g. `weather.temp`) require explicit listing.

View File

@@ -0,0 +1,84 @@
import type { SessionTree } from "../tree/session-tree.js";
import type { SessionId } from "../types/session.types.js";
export interface ParentStateBridgeConfig {
readonly tree: SessionTree;
/** Keys that propagate from a parent session into its descendants. */
readonly bridgedKeys: ReadonlyArray<string>;
}
export interface ParentStateUpdate {
readonly sessionId: SessionId;
readonly key: string;
readonly value: unknown;
}
/**
* Replicates a curated read-only set of keys from a parent session
* into every descendant. Holds the live state per session so descendant
* subscribers can ask for the latest value at any time.
*/
export class ParentStateBridge {
private readonly tree: SessionTree;
private readonly bridgedKeys: ReadonlySet<string>;
private readonly state = new Map<string, Map<string, unknown>>();
public constructor(config: ParentStateBridgeConfig) {
this.tree = config.tree;
this.bridgedKeys = new Set(config.bridgedKeys);
}
public publish(
sessionId: SessionId,
key: string,
value: unknown
): ReadonlyArray<ParentStateUpdate> {
if (!this.bridgedKeys.has(key)) return [];
// Touch the source session entry so consumers see the publish on
// the parent itself.
this.recordValue(sessionId, key, value);
const descendants = this.tree.descendantsOf(sessionId);
const out: ParentStateUpdate[] = [];
for (const descendantId of descendants) {
this.recordValue(descendantId, key, value);
out.push({ sessionId: descendantId, key, value });
}
return out;
}
public read(sessionId: SessionId, key: string): unknown {
if (!this.bridgedKeys.has(key)) return undefined;
return this.state.get(sessionId)?.get(key);
}
public snapshot(sessionId: SessionId): Readonly<Record<string, unknown>> {
const map = this.state.get(sessionId);
if (map === undefined) return {};
return Object.fromEntries(map);
}
public bridgedKeyList(): ReadonlyArray<string> {
return Array.from(this.bridgedKeys);
}
public clear(sessionId?: SessionId): void {
if (sessionId === undefined) {
this.state.clear();
return;
}
this.state.delete(sessionId);
}
private recordValue(
sessionId: SessionId,
key: string,
value: unknown
): void {
let bucket = this.state.get(sessionId);
if (bucket === undefined) {
bucket = new Map();
this.state.set(sessionId, bucket);
}
bucket.set(key, value);
}
}

View File

@@ -0,0 +1,135 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_SESSION_CODES,
type NetworkSessionError,
} from "../error-codes.js";
import { SessionTree } from "../tree/session-tree.js";
import { SessionCapacityEnforcer } from "./session-capacity-enforcer.js";
const setup = (rules: { capacity: number; allowSplitOnOverflow?: boolean }) => {
const events: { event: string; payload: unknown }[] = [];
const tree = new SessionTree();
tree.createSession({
id: "race",
parentId: tree.rootId(),
rules,
});
const enforcer = new SessionCapacityEnforcer({
tree,
bus: { emit: (event, payload) => events.push({ event, payload }) },
});
return { tree, enforcer, events };
};
describe("SessionCapacityEnforcer: admission + split", () => {
it("admits the first peer", () => {
const { enforcer } = setup({ capacity: 1 });
expect(() => enforcer.assertCanAdmit("race", "alice")).not.toThrow();
});
it("rejects past capacity with CAPACITY_EXCEEDED", () => {
const { tree, enforcer } = setup({ capacity: 1 });
const session = tree.get("race");
session.state = { ...session.state, peers: ["alice"] };
try {
enforcer.assertCanAdmit("race", "bob");
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkSessionError).code).toBe(
NETWORK_SESSION_CODES.CAPACITY_EXCEEDED
);
}
});
it("emits session:capacity-exceeded on rejection", () => {
const { tree, enforcer, events } = setup({ capacity: 1 });
tree.get("race").state = {
hostId: null,
peers: ["alice"],
};
try {
enforcer.assertCanAdmit("race", "bob");
} catch {
// expected
}
expect(
events.find((e) => e.event === "session:capacity-exceeded")
).toBeDefined();
});
it("emits session:split-requested only when allowSplitOnOverflow", () => {
const { tree, enforcer, events } = setup({
capacity: 1,
allowSplitOnOverflow: true,
});
tree.get("race").state = {
hostId: null,
peers: ["alice"],
};
try {
enforcer.assertCanAdmit("race", "bob");
} catch {
// expected
}
expect(
events.find((e) => e.event === "session:split-requested")
).toBeDefined();
});
it("split is NOT emitted when not opted in", () => {
const { tree, enforcer, events } = setup({ capacity: 1 });
tree.get("race").state = {
hostId: null,
peers: ["alice"],
};
try {
enforcer.assertCanAdmit("race", "bob");
} catch {
// expected
}
expect(
events.find((e) => e.event === "session:split-requested")
).toBeUndefined();
});
it("already-admitted peer bypasses the check", () => {
const { tree, enforcer } = setup({ capacity: 1 });
tree.get("race").state = {
hostId: null,
peers: ["alice"],
};
expect(() => enforcer.assertCanAdmit("race", "alice")).not.toThrow();
});
it("hasRoom + availableSlots reflect the live state", () => {
const { tree, enforcer } = setup({ capacity: 3 });
expect(enforcer.hasRoom("race")).toBe(true);
expect(enforcer.availableSlots("race")).toBe(3);
tree.get("race").state = {
hostId: null,
peers: ["a", "b", "c"],
};
expect(enforcer.hasRoom("race")).toBe(false);
expect(enforcer.availableSlots("race")).toBe(0);
});
it("availableSlots clamps to zero when the peer list overruns capacity", () => {
const { tree, enforcer } = setup({ capacity: 2 });
tree.get("race").state = {
hostId: null,
peers: ["a", "b", "c", "d"],
};
expect(enforcer.availableSlots("race")).toBe(0);
});
it("default null bus does not throw", () => {
const tree = new SessionTree();
tree.createSession({
id: "x",
parentId: tree.rootId(),
rules: { capacity: 1 },
});
const enforcer = new SessionCapacityEnforcer({ tree });
expect(() => enforcer.assertCanAdmit("x", "alice")).not.toThrow();
});
});

View File

@@ -0,0 +1,81 @@
# `SessionCapacityEnforcer`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Gates `join` calls against `SessionRules.capacity`. When a session is full, raises `CAPACITY_EXCEEDED` and emits `session:capacity-exceeded` on the bus; sessions that opt in via `allowSplitOnOverflow: true` additionally emit `session:split-requested` so a higher-level orchestrator can spawn a sibling session.
## Features
- `assertCanAdmit(sessionId, peerId)` is idempotent for already-joined peers (no-op) and rejects only when admission would actually push past capacity.
- `hasRoom(sessionId)` and `availableSlots(sessionId)` give fast read-only checks without throwing.
- Two distinct bus events: capacity-exceeded fires on every rejection; split-requested fires only when the session opts in.
- Optional bus — the enforcer is fully usable in unit tests via the throw / return values.
## Usage: Basic Example
```ts
import {
SessionCapacityEnforcer,
SessionTree,
} from "@suaveplan/network-plugin-session";
const tree = new SessionTree();
const enforcer = new SessionCapacityEnforcer({ tree });
tree.createSession({
id: "heist-01",
parentId: tree.rootId(),
rules: { capacity: 2 },
});
enforcer.hasRoom("heist-01"); // true
enforcer.assertCanAdmit("heist-01", "alice");
```
## Usage: Advanced Example
```ts
import {
SessionCapacityEnforcer,
SessionTree,
type CapacityBus,
} from "@suaveplan/network-plugin-session";
const events: unknown[] = [];
const bus: CapacityBus = {
emit: (event, payload) => events.push({ event, payload }),
};
const tree = new SessionTree();
const enforcer = new SessionCapacityEnforcer({ tree, bus });
tree.createSession({
id: "race",
parentId: tree.rootId(),
rules: { capacity: 1, allowSplitOnOverflow: true },
});
const session = tree.get("race");
session.state = { ...session.state, peers: ["alice"] };
try {
enforcer.assertCanAdmit("race", "bob");
} catch {
// expected — capacity exceeded
}
console.log(events.map((e: { event: string }) => e.event));
// ["session:capacity-exceeded", "session:split-requested"]
```
## API Reference
- `class SessionCapacityEnforcer(config: SessionCapacityEnforcerConfig)`
- `assertCanAdmit(sessionId, peerId): void` — throws `CAPACITY_EXCEEDED` on rejection.
- `hasRoom(sessionId): boolean`.
- `availableSlots(sessionId): number`.
- `interface CapacityBus` — `{ emit("session:capacity-exceeded" | "session:split-requested", payload): void }`.
## Implementation Notes
- Already-present peers bypass the capacity check so re-join calls never spuriously fail when capacity is exactly at the limit.
- `availableSlots` returns at least zero (clamped) so callers do not need to defend against negative counts when external code over-stuffs the peer list.
- The enforcer never mutates the session — it only inspects + emits + throws. The actual join happens in `SessionManager` once the assertion passes.
- Split events are fire-and-forget: the enforcer does NOT track whether a split was performed; observability subscribers handle that.

View File

@@ -0,0 +1,68 @@
import { NETWORK_SESSION_CODES, NetworkSessionError } from "../error-codes.js";
import type { SessionTree } from "../tree/session-tree.js";
import type { PeerId, SessionId } from "../types/session.types.js";
export interface CapacityBus {
emit(
event: "session:capacity-exceeded" | "session:split-requested",
payload: { sessionId: SessionId; peerId?: PeerId; capacity: number }
): void;
}
export interface SessionCapacityEnforcerConfig {
readonly tree: SessionTree;
readonly bus?: CapacityBus;
}
export class SessionCapacityEnforcer {
private readonly tree: SessionTree;
private readonly bus: CapacityBus | undefined;
public constructor(config: SessionCapacityEnforcerConfig) {
this.tree = config.tree;
this.bus = config.bus;
}
/**
* Throws `CAPACITY_EXCEEDED` when admitting `peerId` would push the
* session past its capacity. Emits `session:capacity-exceeded` (and
* optionally `session:split-requested` when the rules opt in).
*/
public assertCanAdmit(sessionId: SessionId, peerId: PeerId): void {
const session = this.tree.get(sessionId);
if (session.state.peers.includes(peerId)) return;
if (session.state.peers.length < session.rules.capacity) return;
this.bus?.emit("session:capacity-exceeded", {
sessionId,
peerId,
capacity: session.rules.capacity,
});
if (session.rules.allowSplitOnOverflow === true) {
this.bus?.emit("session:split-requested", {
sessionId,
capacity: session.rules.capacity,
});
}
throw new NetworkSessionError(
NETWORK_SESSION_CODES.CAPACITY_EXCEEDED,
"capacity.exceeded",
{
context: {
sessionId,
peerId,
capacity: session.rules.capacity,
},
}
);
}
public hasRoom(sessionId: SessionId): boolean {
const session = this.tree.get(sessionId);
return session.state.peers.length < session.rules.capacity;
}
public availableSlots(sessionId: SessionId): number {
const session = this.tree.get(sessionId);
return Math.max(0, session.rules.capacity - session.state.peers.length);
}
}

View File

@@ -0,0 +1,128 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SessionManager } from "../session/session-manager.js";
import { SessionTree } from "../tree/session-tree.js";
import {
type ElectionBus,
type Scorer,
SessionHostElection,
} from "./session-host-election.js";
const setup = (scorer?: Scorer) => {
const events: unknown[] = [];
const bus: ElectionBus = {
emit: (_event, payload) => events.push(payload),
};
const tree = new SessionTree();
tree.createSession({
id: "race",
parentId: tree.rootId(),
rules: { capacity: 8 },
});
const manager = new SessionManager({ tree });
const election = new SessionHostElection({
tree,
manager,
bus,
...(scorer === undefined ? {} : { scorer }),
});
return { tree, manager, election, events };
};
describe("SessionHostElection: per-session host selection", () => {
it("default scorer is deterministic across runs", () => {
const { manager, election: a } = setup();
manager.join("race", "alice");
manager.join("race", "bob");
const ranked1 = a.scoreboard("race").map((p) => p.peerId);
const { manager: m2, election: b } = setup();
m2.join("race", "alice");
m2.join("race", "bob");
const ranked2 = b.scoreboard("race").map((p) => p.peerId);
expect(ranked2).toEqual(ranked1);
});
it("custom scorer drives the election outcome", () => {
const { manager, election } = setup((peerId) =>
peerId === "alice" ? 100 : 1
);
manager.join("race", "alice");
manager.join("race", "bob");
expect(election.elect("race")).toBe("alice");
});
it("elect returns null for empty session", () => {
const { election } = setup();
expect(election.elect("race")).toBeNull();
});
it("emits host-changed only on transition", () => {
const { manager, election, events } = setup((peerId) =>
peerId === "alice" ? 100 : 1
);
manager.join("race", "alice");
election.elect("race"); // first elect — change from null → alice
election.elect("race"); // re-elect alice — no event
expect(events.length).toBe(1);
});
it("maybeReelect skips when current host still in session", () => {
const { manager, election } = setup();
manager.join("race", "alice");
election.elect("race");
expect(election.maybeReelect("race")).toBe(false);
});
it("maybeReelect re-elects when host left the session", () => {
const { manager, election } = setup();
manager.join("race", "alice");
manager.join("race", "bob");
election.elect("race");
const original = election.scoreboard("race")[0]?.peerId;
if (original !== undefined) manager.leave("race", original);
const ran = election.maybeReelect("race");
expect(ran).toBe(true);
});
it("maybeReelect runs first election when there is no host yet", () => {
const { manager, election } = setup();
manager.join("race", "alice");
const ran = election.maybeReelect("race");
expect(ran).toBe(true);
});
it("scoreboard returns the ranked peer list", () => {
const { manager, election } = setup((peerId) =>
peerId === "alice" ? 100 : peerId === "bob" ? 50 : 1
);
manager.join("race", "charlie");
manager.join("race", "alice");
manager.join("race", "bob");
const ranked = election.scoreboard("race").map((p) => p.peerId);
expect(ranked).toEqual(["alice", "bob", "charlie"]);
});
it("election is independent across sessions", () => {
const { tree, manager } = setup();
tree.createSession({
id: "race-2",
parentId: tree.rootId(),
rules: { capacity: 8 },
});
const election = new SessionHostElection({
tree,
manager,
scorer: (peerId, sessionId) =>
sessionId === "race" && peerId === "alice"
? 100
: sessionId === "race-2" && peerId === "bob"
? 100
: 1,
});
manager.join("race", "alice");
manager.move("alice", "race", "race-2");
manager.join("race", "bob");
expect(election.elect("race")).toBe("bob");
expect(election.elect("race-2")).toBe("alice");
});
});

View File

@@ -0,0 +1,96 @@
# `SessionHostElection`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Per-session host election independent of the mesh-wide Tier 0 host. Picks the highest-scoring peer from the session's `peers` list using a caller-supplied `Scorer` (defaults to a deterministic hash so tests remain reproducible without a real backend). Bus-emits `session:host-changed` on transitions so `SessionRegistry` and consumers stay coherent.
## Features
- Pluggable `Scorer = (peerId, sessionId) => number` so production wires latency / uptime / reputation while tests pin scores.
- Default scorer hashes the `peerId` modulo 1000 — deterministic across runs without external state.
- `scoreboard(sessionId)` returns the ranked peer list (descending score) for debugging / dashboards.
- `maybeReelect(sessionId)` skips the election when the current host is still in the peer list — typical heartbeat path.
- Bus emits only when the elected peer differs from the previous host.
## Usage: Basic Example
```ts
import {
SessionHostElection,
SessionManager,
SessionTree,
} from "@suaveplan/network-plugin-session";
const tree = new SessionTree();
const manager = new SessionManager({ tree });
const election = new SessionHostElection({ tree, manager });
tree.createSession({
id: "heist-01",
parentId: tree.rootId(),
rules: { capacity: 4 },
});
manager.join("heist-01", "alice");
manager.join("heist-01", "bob");
const host = election.elect("heist-01");
console.log(host);
```
## Usage: Advanced Example
```ts
import {
SessionHostElection,
SessionManager,
SessionTree,
type Scorer,
} from "@suaveplan/network-plugin-session";
// Real-life scorer: lower latency wins
const latencies: Record<string, number> = {
alice: 30,
bob: 90,
charlie: 50,
};
const byLowestLatency: Scorer = (peerId) => -1 * (latencies[peerId] ?? 999);
const tree = new SessionTree();
const manager = new SessionManager({ tree });
const election = new SessionHostElection({
tree,
manager,
scorer: byLowestLatency,
bus: { emit: (_event, payload) => console.log(payload) },
});
tree.createSession({
id: "race",
parentId: tree.rootId(),
rules: { capacity: 8 },
});
manager.join("race", "alice");
manager.join("race", "bob");
manager.join("race", "charlie");
election.elect("race"); // alice — lowest latency
manager.leave("race", "alice");
election.maybeReelect("race"); // re-elect because host left → charlie
```
## API Reference
- `class SessionHostElection(config: SessionHostElectionConfig)`
- `scoreboard(sessionId): ReadonlyArray<PeerScore>`.
- `elect(sessionId): PeerId | null` — picks the winner, calls `manager.assignHost`, emits `session:host-changed` on transition.
- `maybeReelect(sessionId): boolean` — `true` when an election ran.
- `interface PeerScore` — `{ peerId, score }`.
- `type Scorer` — `(peerId, sessionId) => number`.
- `interface ElectionBus` — `{ emit("session:host-changed", payload): void }`.
## Implementation Notes
- The election always consults the live peer list — there is no cached scoreboard. Production should wire scorers backed by event-driven metrics (heartbeat-driven latency rolling averages).
- `elect` returns `null` when the session has no peers; the manager accepts `null` as a valid host (treated as "no host yet").
- `maybeReelect` is the typical public entry point — callers can poll it from a heartbeat without coordination.
- Ties go to the first-encountered peer in the sorted list (insertion order preserved for equal scores by `Array.prototype.sort`'s stability guarantee).

View File

@@ -0,0 +1,90 @@
import type { SessionManager } from "../session/session-manager.js";
import type { SessionTree } from "../tree/session-tree.js";
import type { PeerId, SessionId } from "../types/session.types.js";
export interface PeerScore {
readonly peerId: PeerId;
readonly score: number;
}
export type Scorer = (peerId: PeerId, sessionId: SessionId) => number;
export interface ElectionBus {
emit(
event: "session:host-changed",
payload: {
sessionId: SessionId;
previousHostId: PeerId | null;
newHostId: PeerId | null;
}
): void;
}
export interface SessionHostElectionConfig {
readonly tree: SessionTree;
readonly manager: SessionManager;
readonly scorer?: Scorer;
readonly bus?: ElectionBus;
}
const DEFAULT_SCORER: Scorer = (peerId) => {
// Deterministic stable hash — keeps tests reproducible without
// requiring a real latency / uptime backend.
let hash = 0;
for (let i = 0; i < peerId.length; i += 1) {
hash = (hash * 31 + peerId.charCodeAt(i)) >>> 0;
}
return hash % 1_000;
};
export class SessionHostElection {
private readonly tree: SessionTree;
private readonly manager: SessionManager;
private readonly scorer: Scorer;
private readonly bus: ElectionBus | undefined;
public constructor(config: SessionHostElectionConfig) {
this.tree = config.tree;
this.manager = config.manager;
this.scorer = config.scorer ?? DEFAULT_SCORER;
this.bus = config.bus;
}
public scoreboard(sessionId: SessionId): ReadonlyArray<PeerScore> {
const peers = this.tree.get(sessionId).state.peers;
return peers
.map((peerId) => ({
peerId,
score: this.scorer(peerId, sessionId),
}))
.sort((a, b) => b.score - a.score);
}
public elect(sessionId: SessionId): PeerId | null {
const session = this.tree.get(sessionId);
const previous = session.state.hostId;
const ranked = this.scoreboard(sessionId);
const winner = ranked[0]?.peerId ?? null;
this.manager.assignHost(sessionId, winner);
if (winner !== previous) {
this.bus?.emit("session:host-changed", {
sessionId,
previousHostId: previous,
newHostId: winner,
});
}
return winner;
}
/**
* Re-elect when the current host has left the session; otherwise
* leave the existing host in place.
*/
public maybeReelect(sessionId: SessionId): boolean {
const session = this.tree.get(sessionId);
const host = session.state.hostId;
if (host !== null && session.state.peers.includes(host)) return false;
this.elect(sessionId);
return true;
}
}

View File

@@ -0,0 +1,55 @@
# Error codes — `@suaveplan/network-plugin-session`
## Purpose
Frozen registry of every error code raised by the session package. Codes are namespaced under `NET_SESSION` and registered into the global error registry on first import via the side-effect import in `src/index.ts`. Each code maps to a single failure class so consumers branch on `error.code` without parsing message text.
## Features
- Six codes covering session lookup, duplicate creation, parent lookup, peer membership lookup, capacity enforcement, and transition validation.
- `NetworkSessionError` extends `SuaveplanError<NetworkSessionErrorCode>` for cause-chain traversal, redaction, and OTel adapter compatibility.
- Frozen registry — runtime mutation throws.
## Usage: Basic Example
```ts
import {
NETWORK_SESSION_CODES,
NetworkSessionError,
} from "@suaveplan/network-plugin-session";
throw new NetworkSessionError(
NETWORK_SESSION_CODES.SESSION_NOT_FOUND,
"tree.unknown-session",
{ context: { sessionId: "ghost" } }
);
```
## Usage: Advanced Example
```ts
try {
manager.join("heist-01", "alice");
} catch (err) {
if (
err instanceof NetworkSessionError &&
err.code === NETWORK_SESSION_CODES.CAPACITY_EXCEEDED
) {
return suggestSplit(err);
}
throw err;
}
```
## API Reference
- `NETWORK_SESSION_CODES` — frozen `KEY -> "NET_SESSION.KEY"` map.
- `NetworkSessionErrorCode` — TypeScript union of every code value.
- `NetworkSessionError` — `SuaveplanError<NetworkSessionErrorCode>` subclass; `name === "NetworkSessionError"`.
## Implementation Notes
- The registry is constructed via `defineCodes("NET_SESSION", […] as const)` so the type system enumerates the value union; adding a code requires bumping the `as const` tuple.
- Codes are deliberately coarse (one per failure class) so consumers build stable retry / observability policies without churn.
- `INVALID_TRANSITION` covers both the FSM (transition state machine) and the manager-level checks (e.g., joining a session that is mid-leave); the messageKey distinguishes the situations.
- The session-tree's create-time uniqueness check (`SESSION_ALREADY_EXISTS` for duplicate ids) and parent-existence check (`PARENT_NOT_FOUND`) together make cycle formation through the public API impossible, so a separate `CYCLE_DETECTED` code is unnecessary at this layer.

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_SESSION_CODES, NetworkSessionError } from "./error-codes.js";
describe("NETWORK_SESSION_CODES", () => {
it("registers under NET_SESSION", () => {
for (const value of Object.values(NETWORK_SESSION_CODES)) {
expect(value).toMatch(/^NET_SESSION\./);
}
});
it("is frozen", () => {
expect(Object.isFrozen(NETWORK_SESSION_CODES)).toBe(true);
});
it("contains the documented codes", () => {
for (const key of [
"SESSION_NOT_FOUND",
"SESSION_ALREADY_EXISTS",
"PARENT_NOT_FOUND",
"PEER_NOT_IN_SESSION",
"CAPACITY_EXCEEDED",
"INVALID_TRANSITION",
] as const) {
expect(NETWORK_SESSION_CODES).toHaveProperty(key);
}
});
});
describe("NetworkSessionError", () => {
it("extends SuaveplanError", () => {
const err = new NetworkSessionError(
NETWORK_SESSION_CODES.SESSION_NOT_FOUND,
"x"
);
expect(err.name).toBe("NetworkSessionError");
expect(err.code).toBe(NETWORK_SESSION_CODES.SESSION_NOT_FOUND);
});
it("preserves cause", () => {
const cause = new Error("inner");
const err = new NetworkSessionError(
NETWORK_SESSION_CODES.PARENT_NOT_FOUND,
"with-cause",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,28 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
export const NETWORK_SESSION_CODES = defineCodes("NET_SESSION", [
"SESSION_NOT_FOUND",
"SESSION_ALREADY_EXISTS",
"PARENT_NOT_FOUND",
"PEER_NOT_IN_SESSION",
"CAPACITY_EXCEEDED",
"INVALID_TRANSITION",
] as const);
export type NetworkSessionErrorCode =
(typeof NETWORK_SESSION_CODES)[keyof typeof NETWORK_SESSION_CODES];
export class NetworkSessionError extends SuaveplanError<NetworkSessionErrorCode> {
public constructor(
code: NetworkSessionErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetworkSessionError";
}
}

View File

@@ -0,0 +1,87 @@
// Error codes (side-effect registration)
import "./error-codes.js";
// Bridge
export {
ParentStateBridge,
type ParentStateBridgeConfig,
type ParentStateUpdate,
} from "./bridge/parent-state-bridge.js";
// Capacity
export {
type CapacityBus,
SessionCapacityEnforcer,
type SessionCapacityEnforcerConfig,
} from "./capacity/session-capacity-enforcer.js";
// Election
export {
type ElectionBus,
type PeerScore,
type Scorer,
SessionHostElection,
type SessionHostElectionConfig,
} from "./election/session-host-election.js";
// Errors
export {
NETWORK_SESSION_CODES,
NetworkSessionError,
type NetworkSessionErrorCode,
} from "./error-codes.js";
// Plugin
export {
SessionPlugin,
type SessionPluginConfig,
} from "./plugin/session-plugin.js";
// Registry
export {
type RegistryBus,
type SessionDescriptor,
SessionRegistry,
type SessionRegistryConfig,
} from "./registry/session-registry.js";
// Router
export {
SessionRouter,
type SessionRouterConfig,
} from "./router/session-router.js";
// Session manager
export {
type SessionBus,
SessionManager,
type SessionManagerConfig,
} from "./session/session-manager.js";
// Transition
export {
SessionTransitionManager,
type SessionTransitionManagerConfig,
} from "./transition/session-transition-manager.js";
// Tree
export {
SessionTree,
type SessionTreeConfig,
} from "./tree/session-tree.js";
// Types
export type {
SessionEnvelope,
SessionMessage,
SessionScope,
} from "./types/message.types.js";
export type {
PeerId,
Session,
SessionConfig,
SessionId,
SessionRules,
SessionState,
} from "./types/session.types.js";
export type {
SessionSnapshot,
SessionTransition,
TransitionState,
TransitionType,
} from "./types/transition.types.js";
export type {
SessionNode,
SessionPath,
SessionTreeSnapshot,
} from "./types/tree.types.js";

View File

@@ -0,0 +1,80 @@
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SessionPlugin } from "./session-plugin.js";
const newNode = () =>
new NetworkNode({ id: "node-1", networkBus: new NetworkBus() });
describe("SessionPlugin: NetworkPlugin lifecycle", () => {
it("constructs with every subsystem", () => {
const plugin = new SessionPlugin();
expect(plugin.name).toBe("session");
expect(plugin.tree).toBeDefined();
expect(plugin.manager).toBeDefined();
expect(plugin.router).toBeDefined();
expect(plugin.election).toBeDefined();
expect(plugin.transitions).toBeDefined();
expect(plugin.bridge).toBeDefined();
expect(plugin.capacity).toBeDefined();
expect(plugin.registry).toBeDefined();
});
it("install + start + stop + dispose lifecycle", async () => {
const plugin = new SessionPlugin();
const node = newNode();
node.use(plugin);
await node.start();
expect(plugin.host()).not.toBeNull();
expect(plugin.isStarted()).toBe(true);
await node.stop();
expect(plugin.isStarted()).toBe(false);
plugin.dispose();
expect(plugin.host()).toBeNull();
});
it("custom config flows through to subsystems", () => {
const plugin = new SessionPlugin({
tree: { rootId: "world" },
bridge: { bridgedKeys: ["worldTime"] },
scorer: () => 99,
});
expect(plugin.tree.rootId()).toBe("world");
expect(plugin.bridge.bridgedKeyList()).toEqual(["worldTime"]);
});
it("dispose clears transitions + bridge state", () => {
const plugin = new SessionPlugin({
bridge: { bridgedKeys: ["worldTime"] },
});
plugin.transitions.start({
type: "join",
peerId: "alice",
fromSessionId: null,
toSessionId: plugin.tree.rootId(),
});
plugin.bridge.publish(plugin.tree.rootId(), "worldTime", 1);
plugin.dispose();
expect(plugin.transitions.list().length).toBe(0);
expect(plugin.bridge.snapshot(plugin.tree.rootId())).toEqual({});
});
it("end-to-end: tree creation + manager join + router routing", () => {
const plugin = new SessionPlugin();
plugin.tree.createSession({
id: "x",
parentId: plugin.tree.rootId(),
rules: { capacity: 4 },
});
plugin.manager.join("x", "alice");
plugin.manager.join("x", "bob");
const result = plugin.router.route({
id: "m1",
fromPeerId: "alice",
sessionId: "x",
scope: "self",
payload: {},
timestamp: 0,
});
expect(result.recipients).toEqual(["bob"]);
});
});

View File

@@ -0,0 +1,76 @@
# `SessionPlugin`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Single `NetworkPlugin` that bundles every session subsystem (`SessionTree`, `SessionManager`, `SessionRouter`, `SessionHostElection`, `SessionTransitionManager`, `ParentStateBridge`, `SessionCapacityEnforcer`, `SessionRegistry`) and ties their lifecycle to a host `NetworkNode`. Every subsystem is exposed as a public field so consumers extend behaviour by reaching past the plugin facade.
## Features
- All subsystems eagerly constructed so unit tests can build the plugin without a node.
- `dispose` clears the transition manager + parent-state bridge so a re-install starts clean.
- Optional `bridge.bridgedKeys` and `scorer` injection so deployments tune the read-only state set + host-election strategy.
## Usage: Basic Example
```ts
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import { SessionPlugin } from "@suaveplan/network-plugin-session";
const node = new NetworkNode({ id: "n", networkBus: new NetworkBus() });
const plugin = new SessionPlugin();
node.use(plugin);
await node.start();
plugin.tree.createSession({
id: "freeroam-01",
parentId: plugin.tree.rootId(),
rules: { capacity: 30 },
});
plugin.manager.join("freeroam-01", "alice");
```
## Usage: Advanced Example
```ts
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import { SessionPlugin } from "@suaveplan/network-plugin-session";
const plugin = new SessionPlugin({
tree: { rootId: "world", rootCapacity: 10_000 },
bridge: {
bridgedKeys: ["worldTime", "weather", "globalEvent"],
},
scorer: (peerId) => peerId === "alice" ? 100 : 1,
});
const node = new NetworkNode({ id: "n", networkBus: new NetworkBus() });
node.use(plugin);
await node.start();
plugin.tree.createSession({
id: "heist-01",
parentId: "world",
rules: { capacity: 4 },
});
plugin.manager.join("heist-01", "alice");
plugin.manager.join("heist-01", "bob");
plugin.election.elect("heist-01"); // "alice"
```
## API Reference
- `class SessionPlugin implements NetworkPlugin`
- Public fields: `tree`, `manager`, `router`, `election`, `transitions`, `bridge`, `capacity`, `registry`.
- `constructor(config?: SessionPluginConfig)`.
- `install(node): void` / `start(): void` / `stop(): void` / `dispose(): void`.
- `host(): NetworkNode | null` / `isStarted(): boolean`.
- `interface SessionPluginConfig` — `{ tree?, bridge?, scorer?, transition? }`.
## Implementation Notes
- The plugin holds _references_ to subsystems rather than re-exporting their methods so refactors inside any subsystem do not reshape the plugin facade.
- `dispose` only resets transient state (transitions, bridge); the tree + registered sessions persist so a redeploy can resume operation. Callers wanting a hard reset should construct a new plugin.
- The plugin does not auto-subscribe to bus events — wiring `peer:*` flows into the manager / router / election is the consumer's responsibility because event semantics vary by deployment.
- `start` / `stop` only flip a boolean: every subsystem is fully usable without the plugin being started — `start` exists to satisfy the `NetworkPlugin` contract.

View File

@@ -0,0 +1,86 @@
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
import {
ParentStateBridge,
type ParentStateBridgeConfig,
} from "../bridge/parent-state-bridge.js";
import { SessionCapacityEnforcer } from "../capacity/session-capacity-enforcer.js";
import type { Scorer } from "../election/session-host-election.js";
import { SessionHostElection } from "../election/session-host-election.js";
import { SessionRegistry } from "../registry/session-registry.js";
import { SessionRouter } from "../router/session-router.js";
import { SessionManager } from "../session/session-manager.js";
import { SessionTransitionManager } from "../transition/session-transition-manager.js";
import { SessionTree } from "../tree/session-tree.js";
export interface SessionPluginConfig {
readonly tree?: ConstructorParameters<typeof SessionTree>[0];
readonly bridge?: Omit<ParentStateBridgeConfig, "tree">;
readonly scorer?: Scorer;
readonly transition?: ConstructorParameters<
typeof SessionTransitionManager
>[0];
}
export class SessionPlugin implements NetworkPlugin {
public readonly name = "session";
public readonly tree: SessionTree;
public readonly manager: SessionManager;
public readonly router: SessionRouter;
public readonly election: SessionHostElection;
public readonly transitions: SessionTransitionManager;
public readonly bridge: ParentStateBridge;
public readonly capacity: SessionCapacityEnforcer;
public readonly registry: SessionRegistry;
private node: NetworkNode | null = null;
private started = false;
public constructor(config: SessionPluginConfig = {}) {
this.tree = new SessionTree(config.tree);
this.manager = new SessionManager({ tree: this.tree });
this.router = new SessionRouter({
tree: this.tree,
manager: this.manager,
});
this.election = new SessionHostElection({
tree: this.tree,
manager: this.manager,
...(config.scorer === undefined ? {} : { scorer: config.scorer }),
});
this.transitions = new SessionTransitionManager({
...(config.transition ?? {}),
tree: this.tree,
});
this.bridge = new ParentStateBridge({
tree: this.tree,
bridgedKeys: config.bridge?.bridgedKeys ?? [],
});
this.capacity = new SessionCapacityEnforcer({ tree: this.tree });
this.registry = new SessionRegistry({ tree: this.tree });
}
public install(node: NetworkNode): void {
this.node = node;
}
public start(): void {
this.started = true;
}
public stop(): void {
this.started = false;
}
public dispose(): void {
this.transitions.clear();
this.bridge.clear();
this.node = null;
}
public host(): NetworkNode | null {
return this.node;
}
public isStarted(): boolean {
return this.started;
}
}

View File

@@ -0,0 +1,84 @@
# `SessionRegistry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Read-only view over `SessionTree` optimised for the assignment + discovery use cases. Returns `SessionDescriptor` records (id / parentId / hostId / peerCount / capacity / depth) so consumers do not need to walk the tree themselves to answer "which sessions have room?" or "what's the depth of session X?". Optional bus emit (`session:registry-updated`) so subscribers can mirror the registry shape without polling.
## Features
- `describe(sessionId)` returns a frozen-style descriptor for a single session.
- `list()` returns descriptors for every session in the tree.
- `withRoom()` filters to sessions whose `peerCount < capacity` — the typical entry point for join-target selection.
- `publish()` opt-in bus emit so the registry can drive a remote mirror.
## Usage: Basic Example
```ts
import {
SessionRegistry,
SessionTree,
} from "@suaveplan/network-plugin-session";
const tree = new SessionTree();
tree.createSession({
id: "freeroam-01",
parentId: tree.rootId(),
rules: { capacity: 30 },
});
const registry = new SessionRegistry({ tree });
const desc = registry.describe("freeroam-01");
console.log(desc.depth, desc.capacity);
```
## Usage: Advanced Example
```ts
import {
SessionRegistry,
SessionTree,
} from "@suaveplan/network-plugin-session";
const events: unknown[] = [];
const tree = new SessionTree();
tree.createSession({
id: "freeroam-01",
parentId: tree.rootId(),
rules: { capacity: 1 },
});
tree.createSession({
id: "freeroam-02",
parentId: tree.rootId(),
rules: { capacity: 1 },
});
const session = tree.get("freeroam-01");
session.state = { ...session.state, peers: ["alice"] };
const registry = new SessionRegistry({
tree,
bus: { emit: (_event, payload) => events.push(payload) },
});
const open = registry.withRoom();
console.log(open.map((d) => d.id)); // ["root", "freeroam-02"]
registry.publish();
```
## API Reference
- `class SessionRegistry(config: SessionRegistryConfig)`
- `describe(sessionId): SessionDescriptor`.
- `list(): ReadonlyArray<SessionDescriptor>`.
- `withRoom(): ReadonlyArray<SessionDescriptor>`.
- `publish(): void` — no-op when no bus is configured.
- `interface SessionDescriptor` — `{ id, parentId, hostId, peerCount, capacity, depth }`.
- `interface RegistryBus` — `{ emit("session:registry-updated", payload): void }`.
## Implementation Notes
- The registry recomputes on every read — there is no internal cache. The tree is the source of truth, and recomputing per call avoids subtle staleness when peers move mid-iteration.
- `depth` counts edges from the root (root depth = 0).
- `publish` is an explicit caller action so the registry doesn't fire on every tree mutation; consumers wire it to a heartbeat or post-mutation hook.
- For very large trees (>10k sessions), descriptors should be paginated upstream — this is out of scope for the core registry but easy to layer on top of `list()`.

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SessionTree } from "../tree/session-tree.js";
import { SessionRegistry } from "./session-registry.js";
const setup = () => {
const events: unknown[] = [];
const tree = new SessionTree();
tree.createSession({
id: "freeroam",
parentId: tree.rootId(),
rules: { capacity: 30 },
});
tree.createSession({
id: "heist",
parentId: "freeroam",
rules: { capacity: 4 },
});
const registry = new SessionRegistry({
tree,
bus: { emit: (_event, payload) => events.push(payload) },
});
return { tree, registry, events };
};
describe("SessionRegistry: descriptors + filters + publish", () => {
it("describe returns a descriptor with depth", () => {
const { registry } = setup();
const root = registry.describe("root");
expect(root.depth).toBe(0);
const heist = registry.describe("heist");
expect(heist.depth).toBe(2);
});
it("list covers every session in the tree", () => {
const { registry } = setup();
const ids = registry
.list()
.map((d) => d.id)
.sort();
expect(ids).toEqual(["freeroam", "heist", "root"]);
});
it("withRoom filters out full sessions", () => {
const { tree, registry } = setup();
tree.get("heist").state = {
hostId: null,
peers: ["a", "b", "c", "d"],
};
const open = registry
.withRoom()
.map((d) => d.id)
.sort();
expect(open).toEqual(["freeroam", "root"]);
});
it("publish emits descriptors to the bus", () => {
const { registry, events } = setup();
registry.publish();
expect(events.length).toBe(1);
});
it("publish without bus is a no-op", () => {
const tree = new SessionTree();
const registry = new SessionRegistry({ tree });
expect(() => registry.publish()).not.toThrow();
});
it("descriptor reports peer count + capacity + host", () => {
const { tree, registry } = setup();
tree.get("heist").state = {
hostId: "alice",
peers: ["alice", "bob"],
};
const desc = registry.describe("heist");
expect(desc.peerCount).toBe(2);
expect(desc.capacity).toBe(4);
expect(desc.hostId).toBe("alice");
});
});

View File

@@ -0,0 +1,70 @@
import type { SessionTree } from "../tree/session-tree.js";
import type { PeerId, SessionId } from "../types/session.types.js";
export interface SessionDescriptor {
readonly id: SessionId;
readonly parentId: SessionId | null;
readonly hostId: PeerId | null;
readonly peerCount: number;
readonly capacity: number;
readonly depth: number;
}
export interface RegistryBus {
emit(
event: "session:registry-updated",
payload: { descriptors: ReadonlyArray<SessionDescriptor> }
): void;
}
export interface SessionRegistryConfig {
readonly tree: SessionTree;
readonly bus?: RegistryBus;
}
/**
* Lightweight read-only view over the session tree, optimised for
* assignment / discovery queries. Recomputes on demand so callers see
* a coherent snapshot without ever-stale caches.
*/
export class SessionRegistry {
private readonly tree: SessionTree;
private readonly bus: RegistryBus | undefined;
public constructor(config: SessionRegistryConfig) {
this.tree = config.tree;
this.bus = config.bus;
}
public describe(sessionId: SessionId): SessionDescriptor {
const session = this.tree.get(sessionId);
return {
id: session.id,
parentId: session.parentId,
hostId: session.state.hostId,
peerCount: session.state.peers.length,
capacity: session.rules.capacity,
depth: this.depthOf(sessionId),
};
}
public list(): ReadonlyArray<SessionDescriptor> {
const snapshot = this.tree.snapshot();
return snapshot.nodes.map((node) => this.describe(node.session.id));
}
public withRoom(): ReadonlyArray<SessionDescriptor> {
return this.list().filter((d) => d.peerCount < d.capacity);
}
public publish(): void {
if (this.bus === undefined) return;
this.bus.emit("session:registry-updated", {
descriptors: this.list(),
});
}
private depthOf(sessionId: SessionId): number {
return this.tree.getSessionPath(sessionId).length - 1;
}
}

View File

@@ -0,0 +1,164 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SessionManager } from "../session/session-manager.js";
import { SessionTree } from "../tree/session-tree.js";
import { SessionRouter } from "./session-router.js";
const setup = () => {
const tree = new SessionTree();
tree.createSession({
id: "freeroam",
parentId: tree.rootId(),
rules: { capacity: 30 },
});
tree.createSession({
id: "heist",
parentId: "freeroam",
rules: { capacity: 4 },
});
const manager = new SessionManager({ tree });
const router = new SessionRouter({ tree, manager });
return { tree, manager, router };
};
const envelope = (
overrides: Partial<Parameters<SessionRouter["route"]>[0]> = {}
) => ({
id: "m-1",
fromPeerId: "alice",
sessionId: "freeroam",
scope: "self" as const,
payload: {},
timestamp: 0,
...overrides,
});
describe("SessionRouter: scope filtering + recipient resolution", () => {
it("self scope delivers to peers in the session and descendants", () => {
const { manager, router } = setup();
manager.join("freeroam", "alice");
manager.join("freeroam", "bob");
manager.join("heist", "charlie");
const result = router.route(envelope({ scope: "self" }));
expect(result.recipients.sort()).toEqual(["bob", "charlie"]);
});
it("self scope excludes the sender", () => {
const { manager, router } = setup();
manager.join("freeroam", "alice");
const result = router.route(envelope({ scope: "self" }));
expect(result.recipients).toEqual([]);
});
it("broadcast scope behaves like self for the current branch", () => {
const { manager, router } = setup();
manager.join("freeroam", "alice");
manager.join("freeroam", "bob");
const result = router.route(envelope({ scope: "broadcast" }));
expect(result.recipients).toEqual(["bob"]);
});
it("parent scope delivers to the parent session host only", () => {
const { tree, manager, router } = setup();
manager.join("freeroam", "alice");
manager.assignHost("freeroam", "alice");
manager.join("heist", "bob");
const result = router.route(
envelope({
fromPeerId: "bob",
sessionId: "heist",
scope: "parent",
})
);
expect(result.recipients).toEqual(["alice"]);
// tree never mutated by the router
expect(tree.get("freeroam").state.hostId).toBe("alice");
});
it("parent scope returns [] when parent has no host", () => {
const { manager, router } = setup();
manager.join("heist", "bob");
const result = router.route(
envelope({
fromPeerId: "bob",
sessionId: "heist",
scope: "parent",
})
);
expect(result.recipients).toEqual([]);
});
it("parent scope returns [] when sessionId has no parent (root)", () => {
const { router } = setup();
const result = router.route(
envelope({
fromPeerId: "alice",
sessionId: "root",
scope: "parent",
})
);
expect(result.recipients).toEqual([]);
});
it("parent scope excludes self when sender IS the parent host", () => {
const { manager, router } = setup();
manager.join("freeroam", "alice");
manager.assignHost("freeroam", "alice");
manager.join("heist", "alice");
// alice as both heist member and freeroam host
const result = router.route(
envelope({
fromPeerId: "alice",
sessionId: "heist",
scope: "parent",
})
);
expect(result.recipients).toEqual([]);
});
it("envelope without sessionId falls back to sender's leaf", () => {
const { manager, router } = setup();
manager.join("freeroam", "alice");
manager.join("freeroam", "bob");
const result = router.route(
envelope({ sessionId: null, scope: "self" })
);
expect(result.recipients).toEqual(["bob"]);
});
it("envelope without sessionId and unknown sender falls to root", () => {
const { router } = setup();
const result = router.route(
envelope({ sessionId: null, scope: "self" })
);
expect(result.recipients).toEqual([]);
});
it("recipients are deduplicated", () => {
const { manager, router } = setup();
manager.join("freeroam", "alice");
manager.join("freeroam", "bob");
manager.join("heist", "charlie");
// alice (the sender) appears in freeroam; bob in freeroam; charlie in heist
const result = router.route(envelope({ scope: "broadcast" }));
expect(new Set(result.recipients).size).toBe(result.recipients.length);
});
it("dedup branch is exercised when a peer appears in two sessions", () => {
const { tree, router } = setup();
// Bypass the manager's leaf invariant for this defensive path
// — directly inject the same peer into both freeroam and heist.
tree.get("freeroam").state = {
hostId: null,
peers: ["alice", "duplicate"],
};
tree.get("heist").state = {
hostId: null,
peers: ["duplicate"],
};
const result = router.route(envelope({ scope: "broadcast" }));
// duplicate appears once, not twice
expect(result.recipients.filter((r) => r === "duplicate").length).toBe(
1
);
});
});

View File

@@ -0,0 +1,97 @@
# `SessionRouter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Resolves a `SessionEnvelope` into its concrete recipient list. Three scopes — `self` (peers in the sender's session and descendants), `parent` (the parent-session host only), and `broadcast` (every peer in the session branch) — match the typical mesh delivery patterns. The router never sends — it only computes the destination set; the dispatcher consumes the result.
## Features
- Defaults: when `sessionId` is `null`, the router falls back to the sender's current leaf and then to the root.
- `parent` scope returns `[]` when the session has no parent (root) or when the parent host is the sender themselves — keeps loop-back free.
- `broadcast` and `self` are equivalent in the current shape: both walk the session and its descendants. The split survives in the API to allow future "parent + descendants" semantics under `broadcast` if needed.
- Excludes the sender from the recipient list so callers do not need to filter on receive.
## Usage: Basic Example
```ts
import {
SessionRouter,
SessionManager,
SessionTree,
} from "@suaveplan/network-plugin-session";
const tree = new SessionTree();
const manager = new SessionManager({ tree });
const router = new SessionRouter({ tree, manager });
tree.createSession({
id: "freeroam-01",
parentId: tree.rootId(),
rules: { capacity: 30 },
});
manager.join("freeroam-01", "alice");
manager.join("freeroam-01", "bob");
const result = router.route({
id: "m-1",
fromPeerId: "alice",
sessionId: "freeroam-01",
scope: "self",
payload: { ping: 1 },
timestamp: Date.now(),
});
result.recipients; // ["bob"]
```
## Usage: Advanced Example
```ts
import {
SessionRouter,
SessionManager,
SessionTree,
} from "@suaveplan/network-plugin-session";
const tree = new SessionTree();
const manager = new SessionManager({ tree });
const router = new SessionRouter({ tree, manager });
tree.createSession({
id: "freeroam-01",
parentId: tree.rootId(),
rules: { capacity: 30 },
});
tree.createSession({
id: "heist-01",
parentId: "freeroam-01",
rules: { capacity: 4 },
});
manager.join("freeroam-01", "alice");
manager.assignHost("freeroam-01", "alice");
manager.join("heist-01", "bob");
// Bob sends "I'm done!" upward to the freeroam host (alice)
const upward = router.route({
id: "m-1",
fromPeerId: "bob",
sessionId: "heist-01",
scope: "parent",
payload: { kind: "mission-complete" },
timestamp: Date.now(),
});
upward.recipients; // ["alice"]
```
## API Reference
- `class SessionRouter(config: SessionRouterConfig)`
- `route(envelope: SessionEnvelope<TPayload>): SessionMessage<TPayload>`.
## Implementation Notes
- `route` returns a frozen-style result tuple — consumers can iterate `recipients` without ceremony.
- `parent` scope returns `[]` (not an error) when the parent has no host yet — the dispatcher treats this as "nothing to send" rather than a failure.
- Recipients are deduplicated; the same peer appearing in multiple descendant sessions only receives one copy.
- The router consults the live tree on every call, so peer joins / leaves between consecutive routes affect immediate subsequent dispatch — no caching layer to invalidate.

View File

@@ -0,0 +1,83 @@
import type { SessionManager } from "../session/session-manager.js";
import type { SessionTree } from "../tree/session-tree.js";
import type {
SessionEnvelope,
SessionMessage,
} from "../types/message.types.js";
import type { PeerId, SessionId } from "../types/session.types.js";
export interface SessionRouterConfig {
readonly tree: SessionTree;
readonly manager: SessionManager;
}
/**
* Resolves a `SessionEnvelope` into the concrete recipient list per
* its scope (`self` / `parent` / `broadcast`). Pure: every decision
* derives from the tree + manager snapshots at call time.
*/
export class SessionRouter {
private readonly tree: SessionTree;
private readonly manager: SessionManager;
public constructor(config: SessionRouterConfig) {
this.tree = config.tree;
this.manager = config.manager;
}
public route<TPayload>(
envelope: SessionEnvelope<TPayload>
): SessionMessage<TPayload> {
const sessionId =
envelope.sessionId ??
this.manager.currentSessionOf(envelope.fromPeerId) ??
this.tree.rootId();
const recipients = this.recipientsFor(
sessionId,
envelope.fromPeerId,
envelope.scope
);
return { envelope, recipients };
}
private recipientsFor(
sessionId: SessionId,
fromPeerId: PeerId,
scope: SessionEnvelope["scope"]
): ReadonlyArray<PeerId> {
switch (scope) {
case "self":
return this.collectBranch(sessionId, fromPeerId);
case "parent": {
const parentId = this.tree.get(sessionId).parentId;
if (parentId === null) return [];
const parentHost = this.tree.get(parentId).state.hostId;
return parentHost === null || parentHost === fromPeerId
? []
: [parentHost];
}
case "broadcast":
return this.collectBranch(sessionId, fromPeerId);
}
}
private collectBranch(
sessionId: SessionId,
excludePeer: PeerId
): ReadonlyArray<PeerId> {
const out: PeerId[] = [];
const collect = (id: SessionId) => {
const peers = this.tree.get(id).state.peers;
for (const peer of peers) {
if (peer === excludePeer) continue;
if (out.includes(peer)) continue;
out.push(peer);
}
};
collect(sessionId);
for (const descendant of this.tree.descendantsOf(sessionId)) {
collect(descendant);
}
return out;
}
}

View File

@@ -0,0 +1,84 @@
# `SessionManager`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns peer placement across the session tree. Every peer is in exactly one leaf session at any moment; `join` / `leave` / `move` are the only legitimate mutations and each emits a typed bus event so subscribers (`SessionRegistry`, `ParentStateBridge`, dispatch pipelines) react consistently.
## Features
- Single-leaf invariant: `join` automatically removes the peer from its previous session.
- `leave` honours `SessionRules.fallToParentOnLeave` so leaf-game flows can opt in to "go to lobby on quit".
- `move` is atomic — no intermediate state is observable; bus emits a single `session:moved` event.
- `assignHost(sessionId, peerId | null)` couples to the election subsystem; rejects assignments to peers not in the session.
- Reads are O(1); peer-list mutations are O(n) in session size.
## Usage: Basic Example
```ts
import {
SessionManager,
SessionTree,
} from "@suaveplan/network-plugin-session";
const tree = new SessionTree();
const manager = new SessionManager({ tree });
tree.createSession({
id: "freeroam-01",
parentId: tree.rootId(),
rules: { capacity: 30 },
});
manager.join("freeroam-01", "alice");
manager.peersIn("freeroam-01"); // ["alice"]
```
## Usage: Advanced Example
```ts
import {
SessionManager,
SessionTree,
type SessionBus,
} from "@suaveplan/network-plugin-session";
const events: unknown[] = [];
const bus: SessionBus = {
emit: (_event, payload) => events.push(payload),
};
const tree = new SessionTree();
const manager = new SessionManager({ tree, bus });
tree.createSession({
id: "freeroam-01",
parentId: tree.rootId(),
rules: { capacity: 30, fallToParentOnLeave: true },
});
tree.createSession({
id: "heist-01",
parentId: "freeroam-01",
rules: { capacity: 4 },
});
manager.join("freeroam-01", "alice");
manager.move("alice", "freeroam-01", "heist-01");
manager.leave("heist-01", "alice"); // falls through to freeroam-01
```
## API Reference
- `class SessionManager(config: SessionManagerConfig)`
- `currentSessionOf(peerId): SessionId | undefined`.
- `peersIn(sessionId): ReadonlyArray<PeerId>`.
- `join(sessionId, peerId): void`.
- `leave(sessionId, peerId): void` — throws `PEER_NOT_IN_SESSION`; honours `fallToParentOnLeave`.
- `move(peerId, fromSessionId, toSessionId): void`.
- `assignHost(sessionId, peerId | null): void`.
## Implementation Notes
- The manager persists the leaf-session map separately from the tree's per-session peer arrays. Both are kept in sync so consumers can query either side; the manager is the single writer to both.
- `removePeerFromSession` clears `hostId` when the departing peer was the host so the election subsystem knows to re-elect.
- Idempotent `join`: re-joining the current leaf is a no-op (no event emitted) so handlers wired to peer-discovery loops do not flap.
- `move` is implemented as a remove + add on the underlying state; a single `session:moved` event reports the transition so subscribers see one event per move.

View File

@@ -0,0 +1,143 @@
import { NETWORK_SESSION_CODES, NetworkSessionError } from "../error-codes.js";
import type { SessionTree } from "../tree/session-tree.js";
import type { PeerId, Session, SessionId } from "../types/session.types.js";
export interface SessionBus {
emit(
event: "session:joined" | "session:left" | "session:moved",
payload: {
sessionId: SessionId;
peerId: PeerId;
previousSessionId?: SessionId;
}
): void;
}
export interface SessionManagerConfig {
readonly tree: SessionTree;
readonly bus?: SessionBus;
}
const NULL_BUS: SessionBus = { emit: () => undefined };
/**
* Mutates session membership. The tree owns structure; the manager
* owns peer placement (one peer in exactly one leaf at any time).
*/
export class SessionManager {
private readonly tree: SessionTree;
private readonly bus: SessionBus;
private readonly leafByPeer = new Map<PeerId, SessionId>();
public constructor(config: SessionManagerConfig) {
this.tree = config.tree;
this.bus = config.bus ?? NULL_BUS;
}
public currentSessionOf(peerId: PeerId): SessionId | undefined {
return this.leafByPeer.get(peerId);
}
public peersIn(sessionId: SessionId): ReadonlyArray<PeerId> {
return this.tree.get(sessionId).state.peers;
}
public join(sessionId: SessionId, peerId: PeerId): void {
const target = this.tree.get(sessionId);
const previous = this.leafByPeer.get(peerId);
if (previous !== undefined && previous === sessionId) return;
if (previous !== undefined) {
this.removePeerFromSession(previous, peerId);
this.bus.emit("session:left", {
sessionId: previous,
peerId,
});
}
this.addPeerToSession(target, peerId);
this.leafByPeer.set(peerId, sessionId);
this.bus.emit("session:joined", { sessionId, peerId });
}
public leave(sessionId: SessionId, peerId: PeerId): void {
const session = this.tree.get(sessionId);
if (!session.state.peers.includes(peerId)) {
throw new NetworkSessionError(
NETWORK_SESSION_CODES.PEER_NOT_IN_SESSION,
"manager.peer-not-in-session",
{ context: { sessionId, peerId } }
);
}
this.removePeerFromSession(sessionId, peerId);
this.leafByPeer.delete(peerId);
if (
session.rules.fallToParentOnLeave === true &&
session.parentId !== null
) {
this.join(session.parentId, peerId);
this.bus.emit("session:moved", {
sessionId: session.parentId,
peerId,
previousSessionId: sessionId,
});
return;
}
this.bus.emit("session:left", { sessionId, peerId });
}
public move(
peerId: PeerId,
fromSessionId: SessionId,
toSessionId: SessionId
): void {
const from = this.tree.get(fromSessionId);
if (!from.state.peers.includes(peerId)) {
throw new NetworkSessionError(
NETWORK_SESSION_CODES.PEER_NOT_IN_SESSION,
"manager.peer-not-in-session",
{ context: { sessionId: fromSessionId, peerId } }
);
}
const to = this.tree.get(toSessionId);
this.removePeerFromSession(fromSessionId, peerId);
this.addPeerToSession(to, peerId);
this.leafByPeer.set(peerId, toSessionId);
this.bus.emit("session:moved", {
sessionId: toSessionId,
peerId,
previousSessionId: fromSessionId,
});
}
public assignHost(sessionId: SessionId, peerId: PeerId | null): void {
const session = this.tree.get(sessionId);
if (peerId !== null && !session.state.peers.includes(peerId)) {
throw new NetworkSessionError(
NETWORK_SESSION_CODES.PEER_NOT_IN_SESSION,
"manager.host-not-in-session",
{ context: { sessionId, peerId } }
);
}
session.state = { ...session.state, hostId: peerId };
}
private addPeerToSession(session: Session, peerId: PeerId): void {
// Public flows guarantee the peer is not already a member by
// calling `removePeerFromSession` first; we drop the redundant
// includes() check here to keep the helper minimal.
session.state = {
...session.state,
peers: [...session.state.peers, peerId],
};
}
private removePeerFromSession(sessionId: SessionId, peerId: PeerId): void {
const session = this.tree.get(sessionId);
const next = session.state.peers.filter((p) => p !== peerId);
const updatedState = {
...session.state,
peers: next,
...(session.state.hostId === peerId ? { hostId: null } : {}),
};
session.state = updatedState;
}
}

View File

@@ -0,0 +1,147 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_SESSION_CODES,
type NetworkSessionError,
} from "../error-codes.js";
import { SessionTree } from "../tree/session-tree.js";
import { type SessionBus, SessionManager } from "./session-manager.js";
const setup = () => {
const events: { event: string; payload: unknown }[] = [];
const bus: SessionBus = {
emit: (event, payload) => events.push({ event, payload }),
};
const tree = new SessionTree();
tree.createSession({
id: "freeroam",
parentId: tree.rootId(),
rules: { capacity: 30, fallToParentOnLeave: true },
});
tree.createSession({
id: "heist",
parentId: "freeroam",
rules: { capacity: 4 },
});
const manager = new SessionManager({ tree, bus });
return { tree, manager, events };
};
describe("SessionManager: join / leave / move + bus emit", () => {
it("join adds peer to session and tracks leaf-of-peer", () => {
const { manager } = setup();
manager.join("freeroam", "alice");
expect(manager.peersIn("freeroam")).toEqual(["alice"]);
expect(manager.currentSessionOf("alice")).toBe("freeroam");
});
it("join from one session to another removes from previous", () => {
const { manager, events } = setup();
manager.join("freeroam", "alice");
manager.join("heist", "alice");
expect(manager.peersIn("freeroam")).toEqual([]);
expect(manager.peersIn("heist")).toEqual(["alice"]);
expect(events.map((e) => e.event)).toEqual([
"session:joined",
"session:left",
"session:joined",
]);
});
it("re-joining current session is a no-op", () => {
const { manager, events } = setup();
manager.join("freeroam", "alice");
manager.join("freeroam", "alice");
expect(events.length).toBe(1);
});
it("leave with fallToParentOnLeave moves peer to parent", () => {
const { manager, events } = setup();
manager.join("freeroam", "alice");
manager.leave("freeroam", "alice");
expect(manager.currentSessionOf("alice")).toBe("root");
expect(events.find((e) => e.event === "session:moved")).toBeDefined();
});
it("leave without fallthrough removes peer entirely", () => {
const { manager, events } = setup();
manager.join("heist", "alice");
manager.leave("heist", "alice");
expect(manager.currentSessionOf("alice")).toBeUndefined();
expect(events.find((e) => e.event === "session:left")).toBeDefined();
});
it("leave for a non-member throws PEER_NOT_IN_SESSION", () => {
const { manager } = setup();
try {
manager.leave("freeroam", "alice");
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkSessionError).code).toBe(
NETWORK_SESSION_CODES.PEER_NOT_IN_SESSION
);
}
});
it("move atomically transitions and emits session:moved", () => {
const { manager, events } = setup();
manager.join("freeroam", "alice");
manager.move("alice", "freeroam", "heist");
expect(manager.peersIn("freeroam")).toEqual([]);
expect(manager.peersIn("heist")).toEqual(["alice"]);
expect(events.filter((e) => e.event === "session:moved").length).toBe(
1
);
});
it("move for a non-member throws", () => {
const { manager } = setup();
try {
manager.move("alice", "freeroam", "heist");
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkSessionError).code).toBe(
NETWORK_SESSION_CODES.PEER_NOT_IN_SESSION
);
}
});
it("assignHost rejects peer not in session", () => {
const { manager } = setup();
try {
manager.assignHost("freeroam", "ghost");
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkSessionError).code).toBe(
NETWORK_SESSION_CODES.PEER_NOT_IN_SESSION
);
}
});
it("assignHost(null) clears the host", () => {
const { manager, tree } = setup();
manager.join("heist", "alice");
manager.assignHost("heist", "alice");
expect(tree.get("heist").state.hostId).toBe("alice");
manager.assignHost("heist", null);
expect(tree.get("heist").state.hostId).toBeNull();
});
it("removing host peer clears hostId", () => {
const { manager, tree } = setup();
manager.join("heist", "alice");
manager.assignHost("heist", "alice");
manager.leave("heist", "alice");
expect(tree.get("heist").state.hostId).toBeNull();
});
it("default null bus does not throw on join/leave", () => {
const tree = new SessionTree();
tree.createSession({
id: "x",
parentId: tree.rootId(),
rules: { capacity: 1 },
});
const manager = new SessionManager({ tree });
expect(() => manager.join("x", "alice")).not.toThrow();
});
});

View File

@@ -0,0 +1,88 @@
# `SessionTransitionManager`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Tracks join / leave / move flows through the documented six-state life cycle (`idle → requesting → syncing → active → leaving → complete`). Each transition records the operation type, peer id, source / destination sessions, and an optional snapshot the joining peer applies to its local session state. Centralising the FSM keeps the bus / dispatch / state-replication logic decoupled from the underlying state-transition rules.
## Features
- Closed `ALLOWED` table enforces every transition; misordered advances raise `INVALID_TRANSITION` so caller bugs surface immediately.
- `start({ type, peerId, fromSessionId, toSessionId })` returns a fresh `SessionTransition` already advanced to the appropriate initial state (`leaving` for `leave`, `requesting` for `join` / `move`).
- `captureSnapshot(sessionId, state)` produces a `SessionSnapshot` from the live tree + caller-supplied state map; `attachSnapshot(id, snapshot)` binds it to the in-flight transition.
- Injectable clock + id generator so deterministic tests pin timestamps and ids.
- `list()` returns every recorded transition for audit / debugging dashboards; `clear()` resets the registry.
## Usage: Basic Example
```ts
import {
SessionTransitionManager,
SessionTree,
} from "@suaveplan/network-plugin-session";
const tree = new SessionTree();
const transitions = new SessionTransitionManager({ tree });
const t = transitions.start({
type: "join",
peerId: "alice",
fromSessionId: null,
toSessionId: tree.rootId(),
});
transitions.advance(t.id, "syncing");
transitions.advance(t.id, "active");
```
## Usage: Advanced Example
```ts
import {
SessionTransitionManager,
SessionTree,
} from "@suaveplan/network-plugin-session";
let now = 1_000;
const tree = new SessionTree();
const transitions = new SessionTransitionManager({
tree,
clock: () => now,
idGenerator: () => "t-fixed",
});
tree.createSession({
id: "heist",
parentId: tree.rootId(),
rules: { capacity: 4 },
});
const t = transitions.start({
type: "move",
peerId: "alice",
fromSessionId: tree.rootId(),
toSessionId: "heist",
});
const snapshot = transitions.captureSnapshot("heist", {
inventory: ["pistol"],
});
transitions.attachSnapshot(t.id, snapshot);
transitions.advance(t.id, "syncing");
transitions.advance(t.id, "active");
transitions.advance(t.id, "leaving");
transitions.advance(t.id, "complete");
```
## API Reference
- `class SessionTransitionManager(config)`
- `start({ type, peerId, fromSessionId, toSessionId }): SessionTransition`.
- `advance(id, next): SessionTransition` — throws `INVALID_TRANSITION`.
- `attachSnapshot(id, snapshot): void`.
- `captureSnapshot(sessionId, state): SessionSnapshot`.
- `get(id): SessionTransition` / `list(): ReadonlyArray<SessionTransition>` / `clear(): void`.
## Implementation Notes
- The FSM is intentionally linear — `complete` has no successor so once a transition completes, callers must `start` a new one. This keeps the audit trail per-transition rather than entangled with peer-history.
- `start` advances _out_ of `idle` immediately, so the first observable state is meaningful (`requesting` or `leaving`). The brief `idle` state exists as a placeholder for the "constructed but not yet validated" phase.
- `attachSnapshot` does not gate on the current state because some flows (server-driven snapshot push) attach the snapshot before the receiving peer reaches `syncing`.
- `captureSnapshot` reads the live tree's host-id at capture time so receivers can reconcile against the session's host as it was at snapshot time.

View File

@@ -0,0 +1,129 @@
import { NETWORK_SESSION_CODES, NetworkSessionError } from "../error-codes.js";
import type { SessionTree } from "../tree/session-tree.js";
import type { PeerId, SessionId } from "../types/session.types.js";
import type {
SessionSnapshot,
SessionTransition,
TransitionState,
TransitionType,
} from "../types/transition.types.js";
const ALLOWED: Readonly<
Record<TransitionState, ReadonlyArray<TransitionState>>
> = Object.freeze({
idle: ["requesting", "leaving"],
requesting: ["syncing"],
syncing: ["active"],
active: ["leaving"],
leaving: ["complete"],
complete: [],
});
export interface SessionTransitionManagerConfig {
readonly tree: SessionTree;
readonly clock?: () => number;
readonly idGenerator?: () => string;
}
let counter = 0;
export class SessionTransitionManager {
private readonly tree: SessionTree;
private readonly clock: () => number;
private readonly idGenerator: () => string;
private readonly transitions = new Map<string, SessionTransition>();
public constructor(config: SessionTransitionManagerConfig) {
this.tree = config.tree;
this.clock = config.clock ?? (() => Date.now());
this.idGenerator =
config.idGenerator ??
(() => {
counter += 1;
return `t-${counter}`;
});
}
public start(input: {
type: TransitionType;
peerId: PeerId;
fromSessionId: SessionId | null;
toSessionId: SessionId | null;
}): SessionTransition {
const initial: TransitionState =
input.type === "leave" ? "leaving" : "requesting";
const transition: SessionTransition = {
id: this.idGenerator(),
type: input.type,
peerId: input.peerId,
fromSessionId: input.fromSessionId,
toSessionId: input.toSessionId,
state: "idle",
};
this.transitions.set(transition.id, transition);
this.advance(transition.id, initial);
return transition;
}
public advance(id: string, next: TransitionState): SessionTransition {
const transition = this.requireTransition(id);
const allowed = ALLOWED[transition.state];
if (!allowed.includes(next)) {
throw new NetworkSessionError(
NETWORK_SESSION_CODES.INVALID_TRANSITION,
"transition.invalid",
{
context: {
id,
from: transition.state,
to: next,
},
}
);
}
transition.state = next;
return transition;
}
public attachSnapshot(id: string, snapshot: SessionSnapshot): void {
const transition = this.requireTransition(id);
transition.snapshot = snapshot;
}
public captureSnapshot(
sessionId: SessionId,
state: Readonly<Record<string, unknown>>
): SessionSnapshot {
const session = this.tree.get(sessionId);
return {
sessionId,
capturedAt: this.clock(),
hostId: session.state.hostId,
state,
};
}
public get(id: string): SessionTransition {
return this.requireTransition(id);
}
public list(): ReadonlyArray<SessionTransition> {
return Array.from(this.transitions.values());
}
public clear(): void {
this.transitions.clear();
}
private requireTransition(id: string): SessionTransition {
const transition = this.transitions.get(id);
if (transition === undefined) {
throw new NetworkSessionError(
NETWORK_SESSION_CODES.INVALID_TRANSITION,
"transition.unknown",
{ context: { id } }
);
}
return transition;
}
}

View File

@@ -0,0 +1,152 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_SESSION_CODES,
type NetworkSessionError,
} from "../error-codes.js";
import { SessionTree } from "../tree/session-tree.js";
import { SessionTransitionManager } from "./session-transition-manager.js";
const setup = () => {
const tree = new SessionTree();
tree.createSession({
id: "heist",
parentId: tree.rootId(),
rules: { capacity: 4 },
});
const transitions = new SessionTransitionManager({
tree,
clock: () => 1_000,
});
return { tree, transitions };
};
describe("SessionTransitionManager: FSM + snapshot", () => {
it("start(join) advances to requesting", () => {
const { transitions } = setup();
const t = transitions.start({
type: "join",
peerId: "alice",
fromSessionId: null,
toSessionId: "heist",
});
expect(t.state).toBe("requesting");
});
it("start(leave) advances to leaving", () => {
const { transitions } = setup();
const t = transitions.start({
type: "leave",
peerId: "alice",
fromSessionId: "heist",
toSessionId: null,
});
expect(t.state).toBe("leaving");
});
it("happy path: requesting → syncing → active → leaving → complete", () => {
const { transitions } = setup();
const t = transitions.start({
type: "join",
peerId: "alice",
fromSessionId: null,
toSessionId: "heist",
});
transitions.advance(t.id, "syncing");
transitions.advance(t.id, "active");
transitions.advance(t.id, "leaving");
transitions.advance(t.id, "complete");
expect(t.state).toBe("complete");
});
it("invalid advance throws INVALID_TRANSITION", () => {
const { transitions } = setup();
const t = transitions.start({
type: "join",
peerId: "alice",
fromSessionId: null,
toSessionId: "heist",
});
try {
transitions.advance(t.id, "complete");
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkSessionError).code).toBe(
NETWORK_SESSION_CODES.INVALID_TRANSITION
);
}
});
it("unknown transition id throws", () => {
const { transitions } = setup();
try {
transitions.advance("ghost", "syncing");
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkSessionError).code).toBe(
NETWORK_SESSION_CODES.INVALID_TRANSITION
);
}
});
it("captureSnapshot reads the live tree", () => {
const { tree, transitions } = setup();
const session = tree.get("heist");
session.state = { hostId: "alice", peers: ["alice"] };
const snap = transitions.captureSnapshot("heist", { x: 1 });
expect(snap.hostId).toBe("alice");
expect(snap.capturedAt).toBe(1_000);
expect(snap.state).toEqual({ x: 1 });
});
it("attachSnapshot binds a snapshot to the in-flight transition", () => {
const { transitions } = setup();
const t = transitions.start({
type: "join",
peerId: "alice",
fromSessionId: null,
toSessionId: "heist",
});
const snapshot = transitions.captureSnapshot("heist", { x: 1 });
transitions.attachSnapshot(t.id, snapshot);
expect(transitions.get(t.id).snapshot).toBe(snapshot);
});
it("list / clear track every transition", () => {
const { transitions } = setup();
transitions.start({
type: "join",
peerId: "a",
fromSessionId: null,
toSessionId: "heist",
});
transitions.start({
type: "join",
peerId: "b",
fromSessionId: null,
toSessionId: "heist",
});
expect(transitions.list().length).toBe(2);
transitions.clear();
expect(transitions.list().length).toBe(0);
});
it("uses Date.now + auto-incrementing id by default", () => {
const tree = new SessionTree();
tree.createSession({
id: "x",
parentId: tree.rootId(),
rules: { capacity: 1 },
});
const transitions = new SessionTransitionManager({ tree });
const t = transitions.start({
type: "join",
peerId: "a",
fromSessionId: null,
toSessionId: "x",
});
expect(t.id).toMatch(/^t-\d+$/);
// Exercise the default-clock arrow via captureSnapshot
const snap = transitions.captureSnapshot("x", { y: 1 });
expect(typeof snap.capturedAt).toBe("number");
});
});

View File

@@ -0,0 +1,72 @@
# `SessionTree`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the parent-child session graph rooted at a single auto-created root session. Every other session subsystem consults the tree for ancestry / descendant / membership queries, and only the tree mutates session structure (create / destroy). Cycle detection runs at create time so the tree is always a DAG.
## Features
- Auto-creates the root session (`id: "root"` by default, capacity 1,000,000) so consumers can always join the tree without bootstrapping ceremony.
- `createSession` rejects duplicate ids, missing parents, and cycle-forming parent assignments.
- `destroySession` rejects non-empty subtrees and the root.
- `getSessionPath(id)` returns the root → … → target ancestor chain.
- `descendantsOf(id)` runs a DFS; useful for `SessionRouter` broadcast scope.
- `snapshot()` returns a flat read-only `SessionTreeSnapshot` for registry consumers.
## Usage: Basic Example
```ts
import { SessionTree } from "@suaveplan/network-plugin-session";
const tree = new SessionTree();
tree.createSession({
id: "freeroam-01",
parentId: tree.rootId(),
rules: { capacity: 30 },
});
tree.has("freeroam-01"); // true
tree.getSessionPath("freeroam-01"); // ["root", "freeroam-01"]
```
## Usage: Advanced Example
```ts
import { SessionTree } from "@suaveplan/network-plugin-session";
const tree = new SessionTree({ rootId: "world", rootCapacity: 10_000 });
tree.createSession({
id: "freeroam-01",
parentId: "world",
rules: { capacity: 30 },
});
tree.createSession({
id: "heist-01",
parentId: "freeroam-01",
rules: { capacity: 4 },
});
const descendants = tree.descendantsOf("world");
const snap = tree.snapshot();
```
## API Reference
- `class SessionTree(config?)`
- `rootId(): SessionId`.
- `has(id): boolean`.
- `get(id): Session` — throws `SESSION_NOT_FOUND`.
- `createSession(config): Session` — throws `SESSION_ALREADY_EXISTS` / `PARENT_NOT_FOUND` / `CYCLE_DETECTED`.
- `destroySession(id): void` — throws `INVALID_TRANSITION` for root or non-empty subtree.
- `childrenOf(id): ReadonlyArray<SessionId>`.
- `getSessionPath(id): SessionPath` — root-to-target chain.
- `descendantsOf(id): ReadonlyArray<SessionId>` — DFS, target NOT included.
- `snapshot(): SessionTreeSnapshot`.
## Implementation Notes
- The tree stores sessions in a `Map<SessionId, Session>` and child sets in a parallel `Map<SessionId, Set<SessionId>>`. Both reads and writes are O(1) amortised.
- `assertNoCycle` walks the prospective parent's ancestor chain; the loop terminates because the parent ancestry is finite (root has `parentId: null`).
- `destroySession` refuses to remove sessions with children — callers must collapse the subtree manually so the bus-event timeline is explicit.
- `snapshot()` returns the live `Session` references — consumers must treat fields as read-only or call again after mutations to get a fresh view. Future optimisations may freeze them.

View File

@@ -0,0 +1,166 @@
import { NETWORK_SESSION_CODES, NetworkSessionError } from "../error-codes.js";
import type {
Session,
SessionConfig,
SessionId,
} from "../types/session.types.js";
import type { SessionPath, SessionTreeSnapshot } from "../types/tree.types.js";
export interface SessionTreeConfig {
readonly rootId?: SessionId;
readonly rootCapacity?: number;
}
const DEFAULT_ROOT_ID = "root";
const DEFAULT_ROOT_CAPACITY = 1_000_000;
export class SessionTree {
private readonly sessions = new Map<SessionId, Session>();
private readonly children = new Map<SessionId, Set<SessionId>>();
private readonly rootIdValue: SessionId;
public constructor(config: SessionTreeConfig = {}) {
this.rootIdValue = config.rootId ?? DEFAULT_ROOT_ID;
this.sessions.set(this.rootIdValue, {
id: this.rootIdValue,
parentId: null,
rules: { capacity: config.rootCapacity ?? DEFAULT_ROOT_CAPACITY },
state: { hostId: null, peers: [] },
});
this.children.set(this.rootIdValue, new Set());
}
public rootId(): SessionId {
return this.rootIdValue;
}
public has(id: SessionId): boolean {
return this.sessions.has(id);
}
public get(id: SessionId): Session {
const session = this.sessions.get(id);
if (session === undefined) {
throw new NetworkSessionError(
NETWORK_SESSION_CODES.SESSION_NOT_FOUND,
"tree.unknown-session",
{ context: { sessionId: id } }
);
}
return session;
}
public createSession(config: SessionConfig): Session {
if (this.sessions.has(config.id)) {
throw new NetworkSessionError(
NETWORK_SESSION_CODES.SESSION_ALREADY_EXISTS,
"tree.duplicate",
{ context: { sessionId: config.id } }
);
}
if (config.parentId !== null && !this.sessions.has(config.parentId)) {
throw new NetworkSessionError(
NETWORK_SESSION_CODES.PARENT_NOT_FOUND,
"tree.parent-missing",
{ context: { parentId: config.parentId } }
);
}
const session: Session = {
id: config.id,
parentId: config.parentId,
rules: config.rules,
state: { hostId: null, peers: [] },
};
this.sessions.set(session.id, session);
this.children.set(session.id, new Set());
if (session.parentId !== null) {
this.children.get(session.parentId)?.add(session.id);
}
return session;
}
public destroySession(id: SessionId): void {
if (id === this.rootIdValue) {
throw new NetworkSessionError(
NETWORK_SESSION_CODES.INVALID_TRANSITION,
"tree.cannot-destroy-root"
);
}
const session = this.get(id);
const childIds = this.children.get(id);
if (childIds !== undefined && childIds.size > 0) {
throw new NetworkSessionError(
NETWORK_SESSION_CODES.INVALID_TRANSITION,
"tree.has-children",
{ context: { sessionId: id, childCount: childIds.size } }
);
}
this.sessions.delete(id);
this.children.delete(id);
// `destroySession` rejects the root above, so every reachable
// call has a non-null `parentId` whose child set is guaranteed
// to exist by `createSession`.
const parentId = session.parentId as SessionId;
const parentChildren = this.children.get(parentId) as Set<SessionId>;
parentChildren.delete(id);
}
public childrenOf(id: SessionId): ReadonlyArray<SessionId> {
const set = this.children.get(id);
if (set === undefined) {
throw new NetworkSessionError(
NETWORK_SESSION_CODES.SESSION_NOT_FOUND,
"tree.unknown-session",
{ context: { sessionId: id } }
);
}
return Array.from(set);
}
public getSessionPath(id: SessionId): SessionPath {
const path: SessionId[] = [];
let cursor: SessionId | null = id;
while (cursor !== null) {
const session = this.get(cursor);
path.unshift(session.id);
cursor = session.parentId;
}
return path;
}
public descendantsOf(id: SessionId): ReadonlyArray<SessionId> {
if (!this.sessions.has(id)) {
throw new NetworkSessionError(
NETWORK_SESSION_CODES.SESSION_NOT_FOUND,
"tree.unknown-session",
{ context: { sessionId: id } }
);
}
const out: SessionId[] = [];
const stack: SessionId[] = [id];
while (stack.length > 0) {
const current = stack.pop() as SessionId;
// The children map carries an entry for every session in
// `sessions`, so the lookup is non-null on every iteration.
const childIds = this.children.get(current) as Set<SessionId>;
for (const child of childIds) {
out.push(child);
stack.push(child);
}
}
return out;
}
public snapshot(): SessionTreeSnapshot {
const nodes = Array.from(this.sessions.values()).map((session) => {
// children map is in lockstep with sessions, so the lookup
// is always defined.
const childSet = this.children.get(session.id) as Set<SessionId>;
return {
session,
children: Array.from(childSet),
};
});
return { rootId: this.rootIdValue, nodes };
}
}

View File

@@ -0,0 +1,197 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_SESSION_CODES,
type NetworkSessionError,
} from "../error-codes.js";
import { SessionTree } from "./session-tree.js";
const expectThrows = (fn: () => unknown, code: string) => {
try {
fn();
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkSessionError).code).toBe(code);
}
};
describe("SessionTree: tree mutation + queries", () => {
it("auto-creates the root session", () => {
const tree = new SessionTree();
expect(tree.has(tree.rootId())).toBe(true);
});
it("custom root id + capacity respected", () => {
const tree = new SessionTree({
rootId: "world",
rootCapacity: 100,
});
expect(tree.rootId()).toBe("world");
expect(tree.get("world").rules.capacity).toBe(100);
});
it("createSession adds a child under the parent", () => {
const tree = new SessionTree();
const session = tree.createSession({
id: "freeroam",
parentId: tree.rootId(),
rules: { capacity: 30 },
});
expect(session.parentId).toBe(tree.rootId());
expect(tree.childrenOf(tree.rootId())).toContain("freeroam");
});
it("createSession with parentId=null is allowed (sibling root)", () => {
const tree = new SessionTree();
const session = tree.createSession({
id: "isolated",
parentId: null,
rules: { capacity: 1 },
});
expect(session.parentId).toBeNull();
});
it("createSession rejects duplicate ids", () => {
const tree = new SessionTree();
tree.createSession({
id: "x",
parentId: tree.rootId(),
rules: { capacity: 1 },
});
expectThrows(
() =>
tree.createSession({
id: "x",
parentId: tree.rootId(),
rules: { capacity: 1 },
}),
NETWORK_SESSION_CODES.SESSION_ALREADY_EXISTS
);
});
it("createSession rejects missing parents", () => {
const tree = new SessionTree();
expectThrows(
() =>
tree.createSession({
id: "orphan",
parentId: "ghost",
rules: { capacity: 1 },
}),
NETWORK_SESSION_CODES.PARENT_NOT_FOUND
);
});
it("destroySession removes leaf sessions", () => {
const tree = new SessionTree();
tree.createSession({
id: "leaf",
parentId: tree.rootId(),
rules: { capacity: 1 },
});
tree.destroySession("leaf");
expect(tree.has("leaf")).toBe(false);
});
it("destroySession rejects root", () => {
const tree = new SessionTree();
expectThrows(
() => tree.destroySession(tree.rootId()),
NETWORK_SESSION_CODES.INVALID_TRANSITION
);
});
it("destroySession rejects non-empty subtree", () => {
const tree = new SessionTree();
tree.createSession({
id: "parent",
parentId: tree.rootId(),
rules: { capacity: 1 },
});
tree.createSession({
id: "child",
parentId: "parent",
rules: { capacity: 1 },
});
expectThrows(
() => tree.destroySession("parent"),
NETWORK_SESSION_CODES.INVALID_TRANSITION
);
});
it("get / childrenOf / descendantsOf throw on unknown ids", () => {
const tree = new SessionTree();
expectThrows(
() => tree.get("nope"),
NETWORK_SESSION_CODES.SESSION_NOT_FOUND
);
expectThrows(
() => tree.childrenOf("nope"),
NETWORK_SESSION_CODES.SESSION_NOT_FOUND
);
expectThrows(
() => tree.descendantsOf("nope"),
NETWORK_SESSION_CODES.SESSION_NOT_FOUND
);
});
it("getSessionPath returns root → ... → target chain", () => {
const tree = new SessionTree();
tree.createSession({
id: "a",
parentId: tree.rootId(),
rules: { capacity: 1 },
});
tree.createSession({
id: "b",
parentId: "a",
rules: { capacity: 1 },
});
expect(tree.getSessionPath("b")).toEqual([tree.rootId(), "a", "b"]);
});
it("descendantsOf walks the whole subtree (target excluded)", () => {
const tree = new SessionTree();
tree.createSession({
id: "a",
parentId: tree.rootId(),
rules: { capacity: 1 },
});
tree.createSession({
id: "b",
parentId: "a",
rules: { capacity: 1 },
});
tree.createSession({
id: "c",
parentId: "b",
rules: { capacity: 1 },
});
const desc = tree.descendantsOf(tree.rootId());
expect(desc.sort()).toEqual(["a", "b", "c"]);
});
it("snapshot returns root + every node with their children", () => {
const tree = new SessionTree();
tree.createSession({
id: "a",
parentId: tree.rootId(),
rules: { capacity: 1 },
});
const snap = tree.snapshot();
expect(snap.rootId).toBe(tree.rootId());
expect(
snap.nodes.find((n) => n.session.id === tree.rootId())?.children
).toContain("a");
});
it("descendant cleanup updates parent.children when sibling destroyed", () => {
const tree = new SessionTree();
tree.createSession({
id: "a",
parentId: tree.rootId(),
rules: { capacity: 1 },
});
tree.destroySession("a");
expect(tree.childrenOf(tree.rootId())).not.toContain("a");
});
});

View File

@@ -0,0 +1,61 @@
# Message types — `@suaveplan/network-plugin-session`
## Purpose
Wire shapes the `SessionRouter` consumes. `SessionEnvelope` is the raw outbound message a sender hands to the router; `SessionMessage` is the resolved tuple of envelope + recipient list the router emits after applying scope filtering. `SessionScope` is the closed three-value union covering the routing modes (`self` / `parent` / `broadcast`).
## Features
- Discriminated `scope` field so receivers exhaustively switch.
- `sessionId: SessionId | null` accommodates "no session known yet" (e.g., handshake messages) without falling back to a sentinel string.
- Generic `TPayload` type so consumers retain their domain payload through the routing pipeline.
- `recipients` on `SessionMessage` is `ReadonlyArray` so subscribers cannot tamper with the resolved peer list.
## Usage: Basic Example
```ts
import type {
SessionEnvelope,
SessionScope,
} from "@suaveplan/network-plugin-session";
const scope: SessionScope = "self";
const envelope: SessionEnvelope<{ ping: number }> = {
id: "m-1",
fromPeerId: "alice",
sessionId: "freeroam-01",
scope,
payload: { ping: 1 },
timestamp: Date.now(),
};
```
## Usage: Advanced Example
```ts
import type {
SessionEnvelope,
SessionMessage,
} from "@suaveplan/network-plugin-session";
declare const envelope: SessionEnvelope<{ kind: string }>;
declare function dispatch(msg: SessionMessage): void;
dispatch({
envelope,
recipients: ["bob", "charlie"],
});
```
## API Reference
- `type SessionScope` — `"self" | "parent" | "broadcast"`.
- `interface SessionEnvelope<TPayload = unknown>` — `{ id, fromPeerId, sessionId, scope, payload, timestamp }`.
- `interface SessionMessage<TPayload = unknown>` — `{ envelope, recipients }`.
## Implementation Notes
- `self` scope means "deliver to peers in the sender's leaf session and any descendants", which matches the typical chat / state replication pattern.
- `parent` scope means "deliver only to the parent session's host" — used for upward-style messages (mission completion → free-roam authority).
- `broadcast` scope is the broadest within the tree branch — every peer in the session and every descendant session gets the message.
- `sessionId` is captured separately from `scope` because messages with `scope: "parent"` still pin which session's parent to consult; resolution happens in the router.

View File

@@ -0,0 +1,17 @@
import type { SessionId } from "./session.types.js";
export type SessionScope = "self" | "parent" | "broadcast";
export interface SessionEnvelope<TPayload = unknown> {
readonly id: string;
readonly fromPeerId: string;
readonly sessionId: SessionId | null;
readonly scope: SessionScope;
readonly payload: TPayload;
readonly timestamp: number;
}
export interface SessionMessage<TPayload = unknown> {
readonly envelope: SessionEnvelope<TPayload>;
readonly recipients: ReadonlyArray<string>;
}

View File

@@ -0,0 +1,66 @@
# Session types — `@suaveplan/network-plugin-session`
## Purpose
Shared shapes describing a single session node. `SessionConfig` is the immutable identity (`id`, `parentId`, `rules`); `SessionState` carries the mutable runtime fields (`hostId`, `peers`); `Session` glues them together as the canonical record `SessionTree` and `SessionManager` operate on. `SessionRules` separates capacity / fallthrough / split-on-overflow policy from the runtime state so the rules survive host changes and re-elections.
## Features
- `SessionId` and `PeerId` as branded type aliases (currently `string`) so future tightening to opaque tokens is a one-line change.
- `parentId: SessionId | null` makes the root explicit; consumers branch on the `null` instead of inspecting a string sentinel.
- `SessionRules.fallToParentOnLeave` defaults to `false` (peer fully exits the tree); leaf-game flows opt in when desired.
- `SessionState.peers` is `ReadonlyArray<PeerId>` so subscribers cannot mutate the session through a snapshot.
## Usage: Basic Example
```ts
import type {
Session,
SessionConfig,
SessionRules,
} from "@suaveplan/network-plugin-session";
const rules: SessionRules = { capacity: 30 };
const config: SessionConfig = {
id: "freeroam-01",
parentId: null,
rules,
};
declare const session: Session;
console.log(session.state.hostId, session.state.peers.length);
```
## Usage: Advanced Example
```ts
import type {
Session,
SessionRules,
} from "@suaveplan/network-plugin-session";
const heistRules: SessionRules = {
capacity: 4,
fallToParentOnLeave: true,
allowSplitOnOverflow: false,
};
declare const heist: Session;
declare function isHostHere(s: Session, peerId: string): boolean;
const isHost = isHostHere(heist, "alice");
```
## API Reference
- `type SessionId` — `string`.
- `type PeerId` — `string`.
- `interface SessionRules` — `{ capacity, fallToParentOnLeave?, allowSplitOnOverflow? }`.
- `interface SessionConfig` — `{ id, parentId, rules }`.
- `interface SessionState` — `{ hostId, peers }`.
- `interface Session` — `{ id, parentId, rules, state }`.
## Implementation Notes
- `Session.state` is mutable on the parent record so the manager mutates in place; consumers receive frozen copies via `getSession(id)` instead of the raw record so the mutation surface stays internal.
- `hostId: PeerId | null` so the runtime distinguishes "no host yet" (root pre-election) from "host elected but is X".
- `peers` is intentionally `ReadonlyArray` — every mutation goes through `SessionManager.join` / `leave` / `move` so bus events fire deterministically.
- The types live in dedicated files so cross-module type-only imports stay zero-cost at runtime.

View File

@@ -0,0 +1,28 @@
export type SessionId = string;
export type PeerId = string;
export interface SessionRules {
readonly capacity: number;
/** When `true`, peers leaving fall through to the parent session. */
readonly fallToParentOnLeave?: boolean;
/** When `true`, the capacity enforcer requests a split on overflow. */
readonly allowSplitOnOverflow?: boolean;
}
export interface SessionConfig {
readonly id: SessionId;
readonly parentId: SessionId | null;
readonly rules: SessionRules;
}
export interface SessionState {
readonly hostId: PeerId | null;
readonly peers: ReadonlyArray<PeerId>;
}
export interface Session {
readonly id: SessionId;
readonly parentId: SessionId | null;
readonly rules: SessionRules;
state: SessionState;
}

View File

@@ -0,0 +1,65 @@
# Transition types — `@suaveplan/network-plugin-session`
## Purpose
Shapes the `SessionTransitionManager` records as it advances a peer through a join / leave / move flow. `TransitionType` discriminates the high-level operation; `TransitionState` is the closed life-cycle states (`idle → requesting → syncing → active → leaving → complete`); `SessionSnapshot` carries the host-supplied state blob the joining peer needs to catch up; `SessionTransition` is the runtime record the manager mutates.
## Features
- Closed `TransitionType` and `TransitionState` unions so exhaustive switches are honest.
- `SessionSnapshot.state` is a `Readonly<Record<string, unknown>>` so the snapshot survives transport without locking callers into a specific schema.
- `SessionTransition.snapshot` is optional because not every transition requires a snapshot (`leave` skips it).
- `fromSessionId` / `toSessionId` are nullable so the type captures `join` (only `to`), `leave` (only `from`), and `move` (both) without three separate shapes.
## Usage: Basic Example
```ts
import type {
SessionTransition,
TransitionType,
} from "@suaveplan/network-plugin-session";
const t: SessionTransition = {
id: "t-1",
type: "join" as TransitionType,
peerId: "alice",
fromSessionId: null,
toSessionId: "heist-01",
state: "idle",
};
```
## Usage: Advanced Example
```ts
import type {
SessionSnapshot,
SessionTransition,
} from "@suaveplan/network-plugin-session";
declare const snapshot: SessionSnapshot;
const t: SessionTransition = {
id: "t-2",
type: "move",
peerId: "alice",
fromSessionId: "freeroam-01",
toSessionId: "heist-01",
state: "syncing",
snapshot,
};
```
## API Reference
- `type TransitionType` — `"join" | "leave" | "move"`.
- `type TransitionState` — six-state life cycle.
- `interface SessionSnapshot` — `{ sessionId, capturedAt, hostId, state }`.
- `interface SessionTransition` — `{ id, type, peerId, fromSessionId, toSessionId, state, snapshot? }`.
## Implementation Notes
- `state` and `snapshot` are mutable fields on `SessionTransition` because the manager updates the same record across the life cycle; consumers reading should treat the live record as a moving target, not a snapshot.
- The closed `TransitionState` union enables `switch` exhaustiveness in the FSM consumer; adding a state requires updating every consumer that switches on it.
- `SessionSnapshot.state: Record<string, unknown>` is intentionally permissive — the receiving peer applies it to its own session-state subsystem, which is application-defined.
- `move` is implemented as an atomic leave-then-join in the manager but represented as a single transition to keep the audit trail readable.

View File

@@ -0,0 +1,28 @@
import type { PeerId, SessionId } from "./session.types.js";
export type TransitionType = "join" | "leave" | "move";
export type TransitionState =
| "idle"
| "requesting"
| "syncing"
| "active"
| "leaving"
| "complete";
export interface SessionSnapshot {
readonly sessionId: SessionId;
readonly capturedAt: number;
readonly hostId: PeerId | null;
readonly state: Readonly<Record<string, unknown>>;
}
export interface SessionTransition {
readonly id: string;
readonly type: TransitionType;
readonly peerId: PeerId;
readonly fromSessionId: SessionId | null;
readonly toSessionId: SessionId | null;
state: TransitionState;
snapshot?: SessionSnapshot;
}

View File

@@ -0,0 +1,59 @@
# Tree types — `@suaveplan/network-plugin-session`
## Purpose
Shapes describing the session tree as a snapshot. `SessionPath` is the ancestor chain (root → … → target); `SessionNode` pairs a `Session` with its child IDs; `SessionTreeSnapshot` is the read-only view registry consumers (UIs, audit logs) consume.
## Features
- `SessionPath` is `ReadonlyArray<SessionId>` so consumers cannot mutate the path mid-traversal.
- `SessionNode.children` lists IDs only — consumers fetch full nodes via the registry to avoid duplicating session state across the snapshot.
- `SessionTreeSnapshot.nodes` ships in pre-order so `nodes[0]` is always the root.
## Usage: Basic Example
```ts
import type {
SessionPath,
SessionTreeSnapshot,
} from "@suaveplan/network-plugin-session";
declare const path: SessionPath;
declare const snap: SessionTreeSnapshot;
console.log(path.join(" > "));
console.log(snap.rootId, snap.nodes.length);
```
## Usage: Advanced Example
```ts
import type {
SessionNode,
SessionTreeSnapshot,
} from "@suaveplan/network-plugin-session";
declare const snap: SessionTreeSnapshot;
function findNode(
s: SessionTreeSnapshot,
sessionId: string
): SessionNode | undefined {
return s.nodes.find((n) => n.session.id === sessionId);
}
const root = findNode(snap, snap.rootId);
console.log(root?.children);
```
## API Reference
- `type SessionPath` — `ReadonlyArray<SessionId>`.
- `interface SessionNode` — `{ session, children }`.
- `interface SessionTreeSnapshot` — `{ rootId, nodes }`.
## Implementation Notes
- `nodes` is a flat array rather than a recursive shape because flat snapshots serialise cheaply and deduplicate naturally — consumers index by `session.id` once instead of walking a recursive structure on every lookup.
- `children` carries IDs (not full nodes) so the snapshot's footprint stays linear in node count, not quadratic in tree depth.
- `SessionPath` is supplied separately by `SessionTree.getSessionPath(id)`; the snapshot does not embed paths because most consumers only need them on demand.
- `SessionTreeSnapshot` is intentionally immutable; mutating it does not affect the underlying tree, and the underlying tree's mutations do not retroactively appear in held snapshots.

View File

@@ -0,0 +1,13 @@
import type { Session, SessionId } from "./session.types.js";
export type SessionPath = ReadonlyArray<SessionId>;
export interface SessionNode {
readonly session: Session;
readonly children: ReadonlyArray<SessionId>;
}
export interface SessionTreeSnapshot {
readonly rootId: SessionId;
readonly nodes: ReadonlyArray<SessionNode>;
}

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-plugin-session dep-graph", () => {
it("loads index.js and exports every public class", async () => {
const mod = await import("../src/index.js");
expect(mod.SessionTree).toBeDefined();
expect(mod.SessionManager).toBeDefined();
expect(mod.SessionRouter).toBeDefined();
expect(mod.SessionHostElection).toBeDefined();
expect(mod.SessionTransitionManager).toBeDefined();
expect(mod.ParentStateBridge).toBeDefined();
expect(mod.SessionCapacityEnforcer).toBeDefined();
expect(mod.SessionRegistry).toBeDefined();
expect(mod.SessionPlugin).toBeDefined();
});
});

View File

@@ -0,0 +1,7 @@
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,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/network-plugin-session",
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,
},
});