Initial Commit

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

View File

@@ -0,0 +1,20 @@
# @suaveplan/network-plugin-migration - Peer state migration between meshes: coordinator, state-transfer protocol, forwarding tunnel for graceful handoff
## Summary
Peer state migration between meshes: 8-step `MigrationCoordinator`, snapshot serialize/deserialize via `StateTransferProtocol`, graceful-handoff `ForwardingTunnel` that buffers traffic and drains to the new host.
<!-- begin:auto-docs -->
## Documentation tree
- [coordinator](./src/coordinator/migration-coordinator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [migration-coordinator](./src/coordinator/migration-coordinator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [state-transfer](./src/state-transfer/state-transfer-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [state-transfer-protocol](./src/state-transfer/state-transfer-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [tunnel](./src/tunnel/forwarding-tunnel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [forwarding-tunnel](./src/tunnel/forwarding-tunnel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-migration plugin. Four codes cover the surface: `MIGRATION_REJECT…
<!-- end:auto-docs --> - [migration-coordinator](./src/coordinator/migration-coordinator.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-transfer](./src/state-transfer/state-transfer-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-transfer-protocol](./src/state-transfer/state-transfer-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [tunnel](./src/tunnel/forwarding-tunnel.md) — <!-- begin:auto-docs --> _(no submodules)_- [forwarding-tunnel](./src/tunnel/forwarding-tunnel.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-migration plugin. Four codes cover the surface: `MIGRATION_REJECT…
- [migration-coordinator](./src/coordinator/migration-coordinator.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-transfer](./src/state-transfer/state-transfer-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-transfer-protocol](./src/state-transfer/state-transfer-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [tunnel](./src/tunnel/forwarding-tunnel.md) — <!-- begin:auto-docs --> _(no submodules)_- [forwarding-tunnel](./src/tunnel/forwarding-tunnel.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-migration plugin. Four codes cover the surface: `MIGRATION_REJECT…
- [migration-coordinator](./src/coordinator/migration-coordinator.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-transfer](./src/state-transfer/state-transfer-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-transfer-protocol](./src/state-transfer/state-transfer-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [tunnel](./src/tunnel/forwarding-tunnel.md) — <!-- begin:auto-docs --> _(no submodules)_- [forwarding-tunnel](./src/tunnel/forwarding-tunnel.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-migration plugin. Four codes cover the surface: `MIGRATION_REJECT…
- [migration-coordinator](./src/coordinator/migration-coordinator.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-transfer](./src/state-transfer/state-transfer-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_ - [state-transfer-protocol](./src/state-transfer/state-transfer-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [tunnel](./src/tunnel/forwarding-tunnel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [forwarding-tunnel](./src/tunnel/forwarding-tunnel.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-migration plugin. Four codes cover the surface: `MIGRATION_REJECT…

View File

@@ -0,0 +1,49 @@
{
"name": "@suaveplan/network-plugin-migration",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Peer state migration between meshes: coordinator, state-transfer protocol, forwarding tunnel for graceful handoff",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/network": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -0,0 +1,179 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type SnapshotSerializer,
StateTransferProtocol,
} from "../state-transfer/state-transfer-protocol.js";
import {
MigrationCoordinator,
type MigrationDestination,
} from "./migration-coordinator.js";
const passthrough: SnapshotSerializer = {
serialize: (s) => new TextEncoder().encode(JSON.stringify(s)),
deserialize: (b) => JSON.parse(new TextDecoder().decode(b)),
};
function setup(
overrides: {
accept?: () => Promise<boolean>;
receive?: () => Promise<void>;
forwarder?: () => Promise<void>;
captureSnapshot?: () => Promise<{
peerId: string;
schemaVersion: number;
state: unknown;
capturedAt: number;
}>;
timeoutMs?: number;
clock?: () => number;
} = {}
) {
const destination: MigrationDestination = {
accept: overrides.accept ?? (async () => true),
receive: overrides.receive ?? (async () => {}),
};
const coordinator = new MigrationCoordinator({
localPeerId: "alice",
localMeshId: "mesh-A",
stateTransfer: new StateTransferProtocol({ serializer: passthrough }),
destination,
forwarder: overrides.forwarder ?? (async () => {}),
captureSnapshot:
overrides.captureSnapshot ??
(async () => ({
peerId: "alice",
schemaVersion: 1,
state: {},
capturedAt: 0,
})),
timeoutMs: overrides.timeoutMs ?? 30_000,
clock: overrides.clock ?? (() => 0),
});
return coordinator;
}
const goodRequest = {
peerId: "alice",
fromMeshId: "mesh-A",
toMeshId: "mesh-B",
reason: "rebalance",
priority: "graceful" as const,
};
describe("MigrationCoordinator", () => {
it("runs the happy-path 8-step flow", async () => {
const coordinator = setup();
const result = await coordinator.initiateMigration(goodRequest);
expect(result.success).toBe(true);
expect(result.peerId).toBe("alice");
expect(result.newMeshId).toBe("mesh-B");
expect(result.transferredBytes).toBeGreaterThan(0);
});
it("rejects when fromMeshId does not match local", async () => {
const coordinator = setup();
await expect(
coordinator.initiateMigration({
...goodRequest,
fromMeshId: "mesh-Z",
})
).rejects.toThrow("migration.wrong-source");
});
it("rejects when destination refuses", async () => {
const coordinator = setup({ accept: async () => false });
await expect(
coordinator.initiateMigration(goodRequest)
).rejects.toThrow("migration.destination-refused");
});
it("propagates destination accept exceptions", async () => {
const coordinator = setup({
accept: async () => {
throw new Error("dest-bang");
},
});
await expect(
coordinator.initiateMigration(goodRequest)
).rejects.toThrow("dest-bang");
});
it("times out after pack when budget is exceeded", async () => {
let now = 0;
const coordinator = setup({
timeoutMs: 1,
clock: () => now,
captureSnapshot: async () => {
now = 1_000;
return {
peerId: "alice",
schemaVersion: 1,
state: {},
capturedAt: 0,
};
},
});
await expect(
coordinator.initiateMigration(goodRequest)
).rejects.toThrow("migration.timeout-after-pack");
});
it("times out after drain when budget is exceeded", async () => {
let now = 0;
const coordinator = setup({
timeoutMs: 100,
clock: () => now,
// destination.receive races past the budget before drain finishes
receive: async () => {
now = 1_000;
},
});
await expect(
coordinator.initiateMigration(goodRequest)
).rejects.toThrow("migration.timeout-after-drain");
});
it("uses default timeoutMs and clock when not configured", async () => {
const coordinator = new MigrationCoordinator({
localPeerId: "alice",
localMeshId: "mesh-A",
stateTransfer: new StateTransferProtocol({
serializer: passthrough,
}),
destination: {
accept: async () => true,
receive: async () => {},
},
forwarder: async () => {},
captureSnapshot: async () => ({
peerId: "alice",
schemaVersion: 1,
state: {},
capturedAt: 0,
}),
});
const result = await coordinator.initiateMigration(goodRequest);
expect(result.success).toBe(true);
});
it("forwardDuringMigration enqueues into the active tunnel", async () => {
const coordinator = setup();
const { ForwardingTunnel } = await import(
"../tunnel/forwarding-tunnel.js"
);
const tunnel = new ForwardingTunnel({
forwarder: async () => {},
});
await coordinator.forwardDuringMigration(tunnel, {
id: "x",
type: "test",
senderId: "self",
payload: null,
timestamp: 0,
headers: {},
ttl: 8,
visitedNodes: [],
});
expect(tunnel.size()).toBe(1);
});
});

View File

@@ -0,0 +1,97 @@
# MigrationCoordinator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Orchestrates the end-to-end migration flow for moving a peer's state from one mesh to another. Walks through eight ordered steps (validate, open tunnel, accept, snapshot, pack, receive, drain, finish) and surfaces every failure as a typed `MigrateError` so callers can pick the right recovery path. Centralising the coordination prevents the assignment / topology plugins from each implementing their own (drifty) migration logic.
## Features
- 8-step pipeline with a single typed exception per failure mode.
- Pluggable `MigrationDestination` (accept + receive), `captureSnapshot` factory, and `forwarder` for in-flight traffic.
- Configurable `timeoutMs` (default 30 s) checked at two natural break points (after pack, after drain) so partial-progress migrations can roll back.
- Injectable `clock` for deterministic tests.
- Every step's failure mode maps to `MIGRATION_REJECTED`, `STATE_TRANSFER_FAILED`, `MIGRATION_TIMEOUT`, or `TUNNEL_DRAINED`.
## Usage: Basic Example
```ts
import {
MigrationCoordinator,
StateTransferProtocol,
} from "@suaveplan/network-plugin-migration";
const coordinator = new MigrationCoordinator({
localPeerId: "alice",
localMeshId: "mesh-A",
stateTransfer: new StateTransferProtocol({ serializer }),
destination: {
accept: async () => true,
receive: async () => {},
},
forwarder: async () => {},
captureSnapshot: () => ({
peerId: "alice",
schemaVersion: 1,
state: { sessionId: "x" },
capturedAt: Date.now(),
}),
});
const result = await coordinator.initiateMigration({
peerId: "alice",
fromMeshId: "mesh-A",
toMeshId: "mesh-B",
reason: "rebalance",
priority: "graceful",
});
```
## Usage: Advanced Example
```ts
import {
MIGRATE_CODES,
MigrateError,
MigrationCoordinator,
} from "@suaveplan/network-plugin-migration";
let now = 0;
const coordinator = new MigrationCoordinator({
localPeerId: "alice",
localMeshId: "mesh-A",
stateTransfer,
destination,
forwarder,
captureSnapshot,
timeoutMs: 5_000,
clock: () => now,
});
try {
const result = await coordinator.initiateMigration(request);
} catch (err) {
if (
err instanceof MigrateError &&
err.code === MIGRATE_CODES.MIGRATION_TIMEOUT
) {
await rollbackToOriginalMesh(request);
}
throw err;
}
```
## API Reference
- `interface MigrationRequest { peerId, fromMeshId, toMeshId, reason, priority }`.
- `interface MigrationResult { success, peerId, newMeshId, transferredBytes, forwardedEnvelopes, durationMs }`.
- `interface MigrationDestination { accept(req): Promise<boolean>; receive(bytes): Promise<void> }`.
- `interface MigrationCoordinatorConfig { localPeerId, localMeshId, stateTransfer, destination, forwarder, captureSnapshot, timeoutMs?, clock? }`.
- `initiateMigration(request): Promise<MigrationResult>`.
- `forwardDuringMigration(tunnel, envelope)` — enqueue envelope into the active tunnel.
## Implementation Notes
The tunnel is a fresh `ForwardingTunnel` per migration so two concurrent migrations cannot leak each other's buffered traffic. Timeout is checked at two break points — after `pack` (which may stall on serializer I/O) and after `drain` (which may stall on the new host's recv pipe) — rather than via a single `setTimeout` that would race across awaits. `accept` returning `false` is treated as a clean refusal (`MIGRATION_REJECTED`), while a thrown error from `accept` propagates unchanged so consumers can distinguish "destination said no" from "destination crashed".

View File

@@ -0,0 +1,152 @@
import type { MessageEnvelope } from "@suaveplan/network";
import { MIGRATE_CODES, MigrateError } from "../error-codes.js";
import type {
MigrationSnapshot,
StateTransferProtocol,
} from "../state-transfer/state-transfer-protocol.js";
import {
ForwardingTunnel,
type TunnelForwarder,
} from "../tunnel/forwarding-tunnel.js";
/**
* Migration request from the local peer or from the assignment plugin.
*/
export interface MigrationRequest {
readonly peerId: string;
readonly fromMeshId: string;
readonly toMeshId: string;
readonly reason: string;
readonly priority: "graceful" | "urgent";
}
/**
* Outcome of a migration.
*/
export interface MigrationResult {
readonly success: boolean;
readonly peerId: string;
readonly newMeshId: string;
readonly transferredBytes: number;
readonly forwardedEnvelopes: number;
readonly durationMs: number;
}
/**
* Pluggable destination interface — represents the receiving mesh.
*/
export interface MigrationDestination {
accept(request: MigrationRequest): Promise<boolean>;
receive(snapshotBytes: Uint8Array): Promise<void>;
}
/**
* Configuration for `MigrationCoordinator`.
*/
export interface MigrationCoordinatorConfig {
readonly localPeerId: string;
readonly localMeshId: string;
readonly stateTransfer: StateTransferProtocol;
readonly destination: MigrationDestination;
readonly forwarder: TunnelForwarder;
readonly captureSnapshot: () =>
| MigrationSnapshot
| Promise<MigrationSnapshot>;
readonly timeoutMs?: number;
readonly clock?: () => number;
}
/**
* Orchestrates the 8-step migration flow:
*
* 1. validate request fields,
* 2. open the forwarding tunnel,
* 3. ask the destination to `accept`,
* 4. capture the snapshot,
* 5. pack via state-transfer,
* 6. push to destination via `receive`,
* 7. drain the tunnel through `forwarder`,
* 8. return a `MigrationResult`.
*
* Each step has its own failure mode mapped onto `MIGRATE_CODES`.
*/
export class MigrationCoordinator {
private readonly config: MigrationCoordinatorConfig;
private readonly clock: () => number;
private readonly timeoutMs: number;
public constructor(config: MigrationCoordinatorConfig) {
this.config = config;
this.clock = config.clock ?? (() => Date.now());
this.timeoutMs = config.timeoutMs ?? 30_000;
}
public async initiateMigration(
request: MigrationRequest
): Promise<MigrationResult> {
if (request.fromMeshId !== this.config.localMeshId) {
throw new MigrateError(
MIGRATE_CODES.MIGRATION_REJECTED,
"migration.wrong-source",
{
context: {
expected: this.config.localMeshId,
got: request.fromMeshId,
},
}
);
}
const startedAt = this.clock();
const tunnel = new ForwardingTunnel({
forwarder: this.config.forwarder,
});
const accepted = await this.config.destination.accept(request);
if (!accepted) {
throw new MigrateError(
MIGRATE_CODES.MIGRATION_REJECTED,
"migration.destination-refused",
{ context: { peerId: request.peerId } }
);
}
const snapshot = await this.config.captureSnapshot();
const bytes = await this.config.stateTransfer.pack(snapshot);
if (this.clock() - startedAt > this.timeoutMs) {
throw new MigrateError(
MIGRATE_CODES.MIGRATION_TIMEOUT,
"migration.timeout-after-pack",
{
context: {
elapsed: this.clock() - startedAt,
budget: this.timeoutMs,
},
}
);
}
await this.config.destination.receive(bytes);
const forwarded = await tunnel.drain();
const durationMs = this.clock() - startedAt;
if (durationMs > this.timeoutMs) {
throw new MigrateError(
MIGRATE_CODES.MIGRATION_TIMEOUT,
"migration.timeout-after-drain",
{ context: { elapsed: durationMs, budget: this.timeoutMs } }
);
}
return {
success: true,
peerId: request.peerId,
newMeshId: request.toMeshId,
transferredBytes: bytes.byteLength,
forwardedEnvelopes: forwarded,
durationMs,
};
}
public async forwardDuringMigration(
tunnel: ForwardingTunnel,
envelope: MessageEnvelope
): Promise<void> {
tunnel.enqueue(envelope);
}
}

View File

@@ -0,0 +1,63 @@
# MIGRATE_CODES
## Purpose
Frozen registry of error codes for the network-plugin-migration plugin. Four codes cover the surface: `MIGRATION_REJECTED` when the destination mesh refuses the request, `STATE_TRANSFER_FAILED` when the snapshot serialization or transmission fails, `TUNNEL_DRAINED` when the forwarding tunnel attempts a write after drain, and `MIGRATION_TIMEOUT` when the end-to-end flow exceeds its deadline. Companion `MigrateError` extends `SuaveplanError` so consumers pattern-match on `code` rather than parse messages.
## Features
- Four frozen codes registered under the `NET_MIGRATE` namespace.
- `MigrateError extends SuaveplanError<MigrateErrorCode>`.
- `name: "MigrateError"` on every instance — useful for log filters and dashboard alerts.
- `cause` and `context` propagation via `SuaveplanErrorOptions`.
- Codes are stable across versions; the registry only ever grows, never shrinks.
## Usage: Basic Example
```ts
import {
MIGRATE_CODES,
MigrateError,
} from "@suaveplan/network-plugin-migration";
throw new MigrateError(
MIGRATE_CODES.MIGRATION_REJECTED,
"migration.rejected",
{ context: { peerId: "alice", toMeshId: "mesh-B" } }
);
```
## Usage: Advanced Example
```ts
import {
MIGRATE_CODES,
MigrateError,
} from "@suaveplan/network-plugin-migration";
try {
await coordinator.initiateMigration(request);
} catch (err) {
if (err instanceof MigrateError) {
if (err.code === MIGRATE_CODES.MIGRATION_TIMEOUT) {
metrics.counter("migration.timeout").increment();
return rollbackToOriginalMesh(request);
}
if (err.code === MIGRATE_CODES.STATE_TRANSFER_FAILED) {
await retryWithSmallerSnapshot(request);
return;
}
}
throw err;
}
```
## API Reference
- `MIGRATE_CODES` — frozen `{ MIGRATION_REJECTED, STATE_TRANSFER_FAILED, TUNNEL_DRAINED, MIGRATION_TIMEOUT }`.
- `type MigrateErrorCode` — union of registered code values.
- `class MigrateError extends SuaveplanError<MigrateErrorCode>` — typed throwable.
## Implementation Notes
The codes split rejection (declined upfront by destination), transfer-failed (network/serialization fault mid-flow), drain (write-after-drain in the forwarding tunnel), and timeout (deadline exceeded across the full handoff) so callers can choose distinct recovery paths. The package barrel side-effect-imports `./error-codes.js` so subpath consumers register the codes too. `MIGRATION_TIMEOUT` is checked at two natural break points (after `pack` and after `drain`) rather than via a wall-clock `setTimeout`, because mid-await timer races would leave the coordinator in indeterminate states.

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MIGRATE_CODES, MigrateError } from "./error-codes.js";
describe("MIGRATE_CODES", () => {
it("registers under NET_MIGRATE", () => {
for (const value of Object.values(MIGRATE_CODES)) {
expect(value).toMatch(/^NET_MIGRATE\./);
}
});
it("is frozen", () => {
expect(Object.isFrozen(MIGRATE_CODES)).toBe(true);
});
it("contains the documented codes", () => {
for (const key of [
"MIGRATION_REJECTED",
"STATE_TRANSFER_FAILED",
"TUNNEL_DRAINED",
"MIGRATION_TIMEOUT",
] as const) {
expect(MIGRATE_CODES).toHaveProperty(key);
}
});
});
describe("MigrateError", () => {
it("extends SuaveplanError", () => {
const err = new MigrateError(
MIGRATE_CODES.MIGRATION_REJECTED,
"rejected"
);
expect(err.name).toBe("MigrateError");
expect(err.code).toBe(MIGRATE_CODES.MIGRATION_REJECTED);
});
it("preserves cause", () => {
const cause = new Error("inner");
const err = new MigrateError(
MIGRATE_CODES.STATE_TRANSFER_FAILED,
"with-cause",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,22 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const MIGRATE_CODES = defineCodes("NET_MIGRATE", [
"MIGRATION_REJECTED",
"STATE_TRANSFER_FAILED",
"TUNNEL_DRAINED",
"MIGRATION_TIMEOUT",
] as const);
export type MigrateErrorCode =
(typeof MIGRATE_CODES)[keyof typeof MIGRATE_CODES];
export class MigrateError extends SuaveplanError<MigrateErrorCode> {
public constructor(
code: MigrateErrorCode,
messageKey: string,
options?: import("@suaveplan/error").SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "MigrateError";
}
}

View File

@@ -0,0 +1,30 @@
// Error codes (side-effect registration)
import "./error-codes.js";
// Coordinator
export {
MigrationCoordinator,
type MigrationCoordinatorConfig,
type MigrationDestination,
type MigrationRequest,
type MigrationResult,
} from "./coordinator/migration-coordinator.js";
// Errors
export {
MIGRATE_CODES,
MigrateError,
type MigrateErrorCode,
} from "./error-codes.js";
// State transfer
export {
type MigrationSnapshot,
type SnapshotSerializer,
StateTransferProtocol,
type StateTransferProtocolConfig,
} from "./state-transfer/state-transfer-protocol.js";
// Tunnel
export {
ForwardingTunnel,
type ForwardingTunnelConfig,
type TunnelForwarder,
} from "./tunnel/forwarding-tunnel.js";

View File

@@ -0,0 +1,79 @@
# StateTransferProtocol
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Runs the snapshot serialize → wire bytes → deserialize loop for peer migrations and wraps every failure mode as `STATE_TRANSFER_FAILED`. The serializer is injected so consumers can use any encoding (JSON, MessagePack, BitPacker frames) without touching this layer. Centralising the size cap and error wrapping here means the coordinator never has to think about how state actually crosses the wire.
## Features
- Pluggable `SnapshotSerializer` — any object with `serialize` / `deserialize`.
- Configurable `maxBytes` (default 16 MiB) enforced on both pack and unpack.
- Single error code (`STATE_TRANSFER_FAILED`) with `cause` chained from the underlying serializer error.
- Fully async; serializers can do I/O if they need to.
- `getMaxBytes()` exposed for observability dashboards.
## Usage: Basic Example
```ts
import {
StateTransferProtocol,
type SnapshotSerializer,
} from "@suaveplan/network-plugin-migration";
const serializer: SnapshotSerializer = {
serialize: (s) => new TextEncoder().encode(JSON.stringify(s)),
deserialize: (b) => JSON.parse(new TextDecoder().decode(b)),
};
const proto = new StateTransferProtocol({ serializer });
const bytes = await proto.pack({
peerId: "alice",
schemaVersion: 1,
state: { sessionId: "x" },
capturedAt: Date.now(),
});
const snapshot = await proto.unpack(bytes);
```
## Usage: Advanced Example
```ts
import {
MIGRATE_CODES,
MigrateError,
StateTransferProtocol,
} from "@suaveplan/network-plugin-migration";
const proto = new StateTransferProtocol({
serializer: msgpackSerializer,
maxBytes: 1_048_576,
});
try {
const bytes = await proto.pack(largeSnapshot);
await transport.send("alice", bytes);
} catch (err) {
if (
err instanceof MigrateError &&
err.code === MIGRATE_CODES.STATE_TRANSFER_FAILED
) {
await splitSnapshotIntoChunks(largeSnapshot);
}
}
```
## API Reference
- `interface MigrationSnapshot { peerId, schemaVersion, state, capturedAt }`.
- `interface SnapshotSerializer { serialize, deserialize }`.
- `interface StateTransferProtocolConfig { serializer, maxBytes? }`.
- `pack(snapshot): Promise<Uint8Array>` — throws `STATE_TRANSFER_FAILED` on serializer failure or oversize output.
- `unpack(bytes): Promise<MigrationSnapshot>` — throws `STATE_TRANSFER_FAILED` on oversize input or deserializer failure.
- `getMaxBytes(): number`.
## Implementation Notes
Size enforcement runs both at pack and at unpack so an attacker injecting an oversized payload at the destination cannot bypass the limit by skipping the serialize step. We deliberately do not implement chunking here — that's a layer up. Serializer exceptions are chained through `cause` so root-cause analysis sees both the high-level migration error and the underlying serializer fault.

View File

@@ -0,0 +1,100 @@
import { MIGRATE_CODES, MigrateError } from "../error-codes.js";
/**
* One snapshot of a peer's per-mesh state. Opaque from this plugin's
* perspective — the serializer/deserializer is owned by the consumer.
*/
export interface MigrationSnapshot {
readonly peerId: string;
readonly schemaVersion: number;
readonly state: unknown;
readonly capturedAt: number;
}
/**
* Pluggable serializer.
*/
export interface SnapshotSerializer {
serialize(snapshot: MigrationSnapshot): Uint8Array | Promise<Uint8Array>;
deserialize(
bytes: Uint8Array
): MigrationSnapshot | Promise<MigrationSnapshot>;
}
/**
* Configuration for `StateTransferProtocol`.
*/
export interface StateTransferProtocolConfig {
readonly serializer: SnapshotSerializer;
readonly maxBytes?: number;
}
/**
* Runs the snapshot serialize → wire bytes → deserialize loop and
* surfaces every failure mode as `STATE_TRANSFER_FAILED`. The
* serializer is injected so consumers can use any encoding (JSON,
* MessagePack, BitPacker frames) without touching this layer.
*/
export class StateTransferProtocol {
private readonly serializer: SnapshotSerializer;
private readonly maxBytes: number;
public constructor(config: StateTransferProtocolConfig) {
this.serializer = config.serializer;
this.maxBytes = config.maxBytes ?? 16 * 1024 * 1024;
}
public async pack(snapshot: MigrationSnapshot): Promise<Uint8Array> {
let bytes: Uint8Array;
try {
bytes = await this.serializer.serialize(snapshot);
} catch (cause) {
throw new MigrateError(
MIGRATE_CODES.STATE_TRANSFER_FAILED,
"state-transfer.serialize",
{ cause: cause as Error }
);
}
if (bytes.byteLength > this.maxBytes) {
throw new MigrateError(
MIGRATE_CODES.STATE_TRANSFER_FAILED,
"state-transfer.too-large",
{
context: {
size: bytes.byteLength,
max: this.maxBytes,
},
}
);
}
return bytes;
}
public async unpack(bytes: Uint8Array): Promise<MigrationSnapshot> {
if (bytes.byteLength > this.maxBytes) {
throw new MigrateError(
MIGRATE_CODES.STATE_TRANSFER_FAILED,
"state-transfer.too-large",
{
context: {
size: bytes.byteLength,
max: this.maxBytes,
},
}
);
}
try {
return await this.serializer.deserialize(bytes);
} catch (cause) {
throw new MigrateError(
MIGRATE_CODES.STATE_TRANSFER_FAILED,
"state-transfer.deserialize",
{ cause: cause as Error }
);
}
}
public getMaxBytes(): number {
return this.maxBytes;
}
}

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type SnapshotSerializer,
StateTransferProtocol,
} from "./state-transfer-protocol.js";
const passthroughSerializer: SnapshotSerializer = {
serialize: (s) => new TextEncoder().encode(JSON.stringify(s)),
deserialize: (b) => JSON.parse(new TextDecoder().decode(b)),
};
describe("StateTransferProtocol", () => {
it("round-trips a snapshot", async () => {
const proto = new StateTransferProtocol({
serializer: passthroughSerializer,
});
const snapshot = {
peerId: "alice",
schemaVersion: 1,
state: { x: 1 },
capturedAt: 0,
};
const bytes = await proto.pack(snapshot);
const unpacked = await proto.unpack(bytes);
expect(unpacked).toEqual(snapshot);
});
it("rejects oversized packs", async () => {
const proto = new StateTransferProtocol({
serializer: passthroughSerializer,
maxBytes: 10,
});
const snapshot = {
peerId: "alice",
schemaVersion: 1,
state: { padding: "x".repeat(1000) },
capturedAt: 0,
};
await expect(proto.pack(snapshot)).rejects.toThrow(
"state-transfer.too-large"
);
});
it("rejects oversized unpacks", async () => {
const proto = new StateTransferProtocol({
serializer: passthroughSerializer,
maxBytes: 10,
});
await expect(proto.unpack(new Uint8Array(20))).rejects.toThrow(
"state-transfer.too-large"
);
});
it("wraps serializer exceptions as STATE_TRANSFER_FAILED", async () => {
const proto = new StateTransferProtocol({
serializer: {
serialize: () => {
throw new Error("ser-bang");
},
deserialize: () => {
throw new Error("de-bang");
},
},
});
await expect(
proto.pack({
peerId: "x",
schemaVersion: 1,
state: {},
capturedAt: 0,
})
).rejects.toThrow("state-transfer.serialize");
await expect(proto.unpack(new Uint8Array(2))).rejects.toThrow(
"state-transfer.deserialize"
);
});
it("uses default maxBytes when not configured", () => {
const proto = new StateTransferProtocol({
serializer: passthroughSerializer,
});
expect(proto.getMaxBytes()).toBe(16 * 1024 * 1024);
});
it("supports async serializers", async () => {
const proto = new StateTransferProtocol({
serializer: {
serialize: async (s) =>
new TextEncoder().encode(JSON.stringify(s)),
deserialize: async (b) =>
JSON.parse(new TextDecoder().decode(b)),
},
});
const snapshot = {
peerId: "alice",
schemaVersion: 1,
state: {},
capturedAt: 0,
};
const bytes = await proto.pack(snapshot);
expect(bytes.byteLength).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,73 @@
# ForwardingTunnel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Buffers traffic addressed to a migrating peer at the old host and forwards it to the new host once the migration coordinator says the new home is ready. The tunnel is one-shot: after `drain()` resolves, no further `enqueue` calls are accepted — that signal lets the surrounding plugin pipeline switch routing back to the normal mesh transport.
## Features
- `enqueue(envelope)` is the only ingestion point.
- Configurable `bufferLimit` (default 1000) with drop-oldest backpressure.
- `drain()` flushes the buffer through the injected `forwarder` in FIFO order, returns the count of forwarded envelopes.
- Post-drain `enqueue` raises `TUNNEL_DRAINED` so callers see the closure deterministically rather than silently losing traffic.
- `size()` and `isDrained()` exposed for observability.
## Usage: Basic Example
```ts
import { ForwardingTunnel } from "@suaveplan/network-plugin-migration";
const tunnel = new ForwardingTunnel({
forwarder: async (envelope) => {
await newTransport.send("bob", envelope);
},
});
tunnel.enqueue(inboundEnvelope);
await tunnel.drain();
```
## Usage: Advanced Example
```ts
import {
ForwardingTunnel,
MIGRATE_CODES,
MigrateError,
} from "@suaveplan/network-plugin-migration";
const tunnel = new ForwardingTunnel({
forwarder: realForwarder,
bufferLimit: 100,
});
for (const env of incoming) tunnel.enqueue(env);
const forwarded = await tunnel.drain();
metrics.gauge("migration.forwarded", forwarded);
try {
tunnel.enqueue(lateArrival);
} catch (err) {
if (
err instanceof MigrateError &&
err.code === MIGRATE_CODES.TUNNEL_DRAINED
) {
await newTransport.send("bob", lateArrival);
}
}
```
## API Reference
- `type TunnelForwarder = (envelope) => Promise<void>`.
- `interface ForwardingTunnelConfig { forwarder, bufferLimit? }`.
- `enqueue(envelope)` — throws `TUNNEL_DRAINED` after `drain()` has run.
- `size(): number` / `isDrained(): boolean`.
- `drain(): Promise<number>` — returns the number of forwarded envelopes.
## Implementation Notes
Drop-oldest is the right backpressure because the new host can request a missing message via the registry-sync layer, but a stalled tunnel that filled up its buffer would block the migration entirely. The tunnel does not try to dedupe envelopes by id — that's the responsibility of the recipient's idempotency layer. `drain()` is single-shot and the FIFO order matches the order of `enqueue` calls, which matters for stateful recipients that rely on insertion order.

View File

@@ -0,0 +1,90 @@
import type { MessageEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ForwardingTunnel } from "./forwarding-tunnel.js";
function envelope(id: string): MessageEnvelope {
return {
id,
type: "test",
senderId: "self",
payload: null,
timestamp: 0,
headers: {},
ttl: 8,
visitedNodes: [],
} as MessageEnvelope;
}
describe("ForwardingTunnel", () => {
it("buffers envelopes and forwards in order on drain", async () => {
const forwarded: string[] = [];
const tunnel = new ForwardingTunnel({
forwarder: async (env) => {
forwarded.push(env.id);
},
});
tunnel.enqueue(envelope("a"));
tunnel.enqueue(envelope("b"));
tunnel.enqueue(envelope("c"));
const count = await tunnel.drain();
expect(forwarded).toEqual(["a", "b", "c"]);
expect(count).toBe(3);
});
it("rejects enqueue after drain", async () => {
const tunnel = new ForwardingTunnel({
forwarder: async () => {},
});
await tunnel.drain();
expect(() => tunnel.enqueue(envelope("late"))).toThrow(
"tunnel.drained"
);
});
it("drops oldest when buffer is full", async () => {
const forwarded: string[] = [];
const tunnel = new ForwardingTunnel({
forwarder: async (env) => {
forwarded.push(env.id);
},
bufferLimit: 2,
});
tunnel.enqueue(envelope("a"));
tunnel.enqueue(envelope("b"));
tunnel.enqueue(envelope("c"));
const count = await tunnel.drain();
expect(forwarded).toEqual(["b", "c"]);
expect(count).toBe(2);
});
it("size and isDrained reflect state", async () => {
const tunnel = new ForwardingTunnel({
forwarder: async () => {},
});
expect(tunnel.size()).toBe(0);
expect(tunnel.isDrained()).toBe(false);
tunnel.enqueue(envelope("x"));
expect(tunnel.size()).toBe(1);
await tunnel.drain();
expect(tunnel.isDrained()).toBe(true);
expect(tunnel.size()).toBe(0);
});
it("uses default bufferLimit when not configured", () => {
const tunnel = new ForwardingTunnel({
forwarder: async () => {},
});
for (let i = 0; i < 50; i++) tunnel.enqueue(envelope(`m${i}`));
expect(tunnel.size()).toBe(50);
});
it("propagates forwarder exceptions", async () => {
const tunnel = new ForwardingTunnel({
forwarder: async () => {
throw new Error("forward-bang");
},
});
tunnel.enqueue(envelope("x"));
await expect(tunnel.drain()).rejects.toThrow("forward-bang");
});
});

View File

@@ -0,0 +1,66 @@
import type { MessageEnvelope } from "@suaveplan/network";
import { MIGRATE_CODES, MigrateError } from "../error-codes.js";
/**
* Function that forwards a buffered envelope to its new home.
*/
export type TunnelForwarder = (envelope: MessageEnvelope) => Promise<void>;
/**
* Configuration for `ForwardingTunnel`.
*/
export interface ForwardingTunnelConfig {
readonly forwarder: TunnelForwarder;
readonly bufferLimit?: number;
}
/**
* Buffers traffic addressed to a migrating peer and forwards it to the
* new host. Once `drain()` resolves, no further `enqueue` calls are
* accepted — the tunnel is one-shot.
*/
export class ForwardingTunnel {
private readonly forwarder: TunnelForwarder;
private readonly bufferLimit: number;
private buffer: MessageEnvelope[] = [];
private drained = false;
public constructor(config: ForwardingTunnelConfig) {
this.forwarder = config.forwarder;
this.bufferLimit = config.bufferLimit ?? 1_000;
}
public enqueue(envelope: MessageEnvelope): void {
if (this.drained) {
throw new MigrateError(
MIGRATE_CODES.TUNNEL_DRAINED,
"tunnel.drained",
{ context: { envelopeId: envelope.id } }
);
}
if (this.buffer.length >= this.bufferLimit) {
// drop-oldest backpressure to bound memory
this.buffer.shift();
}
this.buffer.push(envelope);
}
public size(): number {
return this.buffer.length;
}
public isDrained(): boolean {
return this.drained;
}
public async drain(): Promise<number> {
const count = this.buffer.length;
const items = this.buffer;
this.buffer = [];
this.drained = true;
for (const envelope of items) {
await this.forwarder(envelope);
}
return count;
}
}

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-plugin-migration dep-graph", () => {
it("loads index.js (acyclic static imports)", async () => {
const mod = await import("../src/index.js");
expect(mod.MigrationCoordinator).toBeDefined();
expect(mod.StateTransferProtocol).toBeDefined();
expect(mod.ForwardingTunnel).toBeDefined();
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
MigrationCoordinator,
type MigrationDestination,
type SnapshotSerializer,
StateTransferProtocol,
} from "../../src/index.js";
describe("migration end-to-end", () => {
it("packs, transmits, and drains", async () => {
const received: Uint8Array[] = [];
const destination: MigrationDestination = {
accept: async () => true,
receive: async (b) => {
received.push(b);
},
};
const serializer: SnapshotSerializer = {
serialize: (s) => new TextEncoder().encode(JSON.stringify(s)),
deserialize: (b) => JSON.parse(new TextDecoder().decode(b)),
};
const coordinator = new MigrationCoordinator({
localPeerId: "alice",
localMeshId: "mesh-A",
stateTransfer: new StateTransferProtocol({ serializer }),
destination,
forwarder: async () => {},
captureSnapshot: async () => ({
peerId: "alice",
schemaVersion: 1,
state: { sessionId: "x", counter: 42 },
capturedAt: 0,
}),
});
const result = await coordinator.initiateMigration({
peerId: "alice",
fromMeshId: "mesh-A",
toMeshId: "mesh-B",
reason: "test",
priority: "graceful",
});
expect(result.success).toBe(true);
expect(received).toHaveLength(1);
const decoded = await new StateTransferProtocol({
serializer,
}).unpack(received[0]!);
expect((decoded.state as { counter: number }).counter).toBe(42);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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