Initial Commit
This commit is contained in:
32
packages/networking/network/network-plugin-session/README.md
Normal file
32
packages/networking/network/network-plugin-session/README.md
Normal 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…
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()`.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"references": [{ "path": "./tsconfig.lib.json" }]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user