first commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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.