Initial Commit

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

View File

@@ -0,0 +1,32 @@
# @suaveplan/network-plugin-state - Mesh-wide synchronized state with delta compression, path-specific conflict resolution, Bloom-filter divergence detection, and resource-aware sync
## Summary
Mesh-wide synchronized state for the Suaveplan network. Path-based `StateManager` (versioned history with sequence numbers + per-path subscriptions); `DeltaCompressor` computing structural deltas between snapshots with round-trip verification; `StateReconciler` covering the five strategies (`latest-wins` / `remote-wins` / `local-wins` / `deep-merge` / `custom`) with `PathStrategyRegistry` so different parts of the tree honour different policies; `BloomFilterComparator` for compact divergence detection; `StateSyncEngine` orchestrating full-state sync for new peers and delta sync for existing peers; `ResourceAdapter` mapping the four device profiles (`EDGE` / `MOBILE` / `DESKTOP` / `CLOUD`) to compression / retention / sync-frequency knobs; `StateHistoryPruner` enforcing retention windows and memory bounds. Wrapped in a single `StatePlugin` satisfying the `NetworkPlugin` lifecycle.
<!-- begin:auto-docs -->
## Documentation tree
- [bloom](./src/bloom/bloom-filter-comparator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bloom-filter-comparator](./src/bloom/bloom-filter-comparator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [delta](./src/delta/delta-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [delta-compressor](./src/delta/delta-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/state-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [state-plugin](./src/plugin/state-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [pruner](./src/pruner/state-history-pruner.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [state-history-pruner](./src/pruner/state-history-pruner.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [reconciler](./src/reconciler/state-reconciler.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [state-reconciler](./src/reconciler/state-reconciler.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [resource](./src/resource/resource-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [resource-adapter](./src/resource/resource-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [state](./src/state/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [state-manager](./src/state/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [strategy](./src/strategy/path-strategy-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [path-strategy-registry](./src/strategy/path-strategy-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sync](./src/sync/state-sync-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [state-sync-engine](./src/sync/state-sync-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the state package. Codes are namespaced under `NET_STATE` and registered…
<!-- end:auto-docs --> - [bloom-filter-comparator](./src/bloom/bloom-filter-comparator.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta](./src/delta/delta-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-compressor](./src/delta/delta-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/state-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-plugin](./src/plugin/state-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [pruner](./src/pruner/state-history-pruner.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-history-pruner](./src/pruner/state-history-pruner.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciler](./src/reconciler/state-reconciler.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-reconciler](./src/reconciler/state-reconciler.md) — <!-- begin:auto-docs --> _(no submodules)_- [resource](./src/resource/resource-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [resource-adapter](./src/resource/resource-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [state](./src/state/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-manager](./src/state/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [strategy](./src/strategy/path-strategy-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [path-strategy-registry](./src/strategy/path-strategy-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/state-sync-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-sync-engine](./src/sync/state-sync-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the state package. Codes are namespaced under `NET_STATE` and registered…
- [bloom-filter-comparator](./src/bloom/bloom-filter-comparator.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta](./src/delta/delta-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-compressor](./src/delta/delta-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/state-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-plugin](./src/plugin/state-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [pruner](./src/pruner/state-history-pruner.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-history-pruner](./src/pruner/state-history-pruner.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciler](./src/reconciler/state-reconciler.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-reconciler](./src/reconciler/state-reconciler.md) — <!-- begin:auto-docs --> _(no submodules)_- [resource](./src/resource/resource-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [resource-adapter](./src/resource/resource-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [state](./src/state/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-manager](./src/state/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [strategy](./src/strategy/path-strategy-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [path-strategy-registry](./src/strategy/path-strategy-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/state-sync-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-sync-engine](./src/sync/state-sync-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the state package. Codes are namespaced under `NET_STATE` and registered…
- [bloom-filter-comparator](./src/bloom/bloom-filter-comparator.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta](./src/delta/delta-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-compressor](./src/delta/delta-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/state-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-plugin](./src/plugin/state-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [pruner](./src/pruner/state-history-pruner.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-history-pruner](./src/pruner/state-history-pruner.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciler](./src/reconciler/state-reconciler.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-reconciler](./src/reconciler/state-reconciler.md) — <!-- begin:auto-docs --> _(no submodules)_- [resource](./src/resource/resource-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [resource-adapter](./src/resource/resource-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [state](./src/state/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-manager](./src/state/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [strategy](./src/strategy/path-strategy-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [path-strategy-registry](./src/strategy/path-strategy-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/state-sync-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-sync-engine](./src/sync/state-sync-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the state package. Codes are namespaced under `NET_STATE` and registered…
- [bloom-filter-comparator](./src/bloom/bloom-filter-comparator.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta](./src/delta/delta-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_ - [delta-compressor](./src/delta/delta-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/state-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [state-plugin](./src/plugin/state-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [pruner](./src/pruner/state-history-pruner.md) — <!-- begin:auto-docs --> _(no submodules)_ - [state-history-pruner](./src/pruner/state-history-pruner.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciler](./src/reconciler/state-reconciler.md) — <!-- begin:auto-docs --> _(no submodules)_ - [state-reconciler](./src/reconciler/state-reconciler.md) — <!-- begin:auto-docs --> _(no submodules)_- [resource](./src/resource/resource-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ - [resource-adapter](./src/resource/resource-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [state](./src/state/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [state-manager](./src/state/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [strategy](./src/strategy/path-strategy-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [path-strategy-registry](./src/strategy/path-strategy-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/state-sync-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [state-sync-engine](./src/sync/state-sync-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the state package. Codes are namespaced under `NET_STATE` and registered…

View File

@@ -0,0 +1,49 @@
{
"name": "@suaveplan/network-plugin-state",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Mesh-wide synchronized state with delta compression, path-specific conflict resolution, Bloom-filter divergence detection, and resource-aware sync",
"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,65 @@
# `BloomFilterComparator` + `BloomFilter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Compact divergence-detection primitive for the sync engine. Peers exchange Bloom-filter byte buffers summarising their known state paths; the comparator computes which local paths are missing from the remote filter so the sender can target only those paths in the next delta sync. Avoids full-state exchange for routine reconciliation.
## Features
- `BloomFilter` sized for a configurable expected item count + false-positive rate (default 1%); bits / hashes derived analytically.
- `BloomFilterComparator.summarise(paths, config)` builds a fresh filter from a path set; `missingFromRemote(localPaths, remote)` returns the list of locals the remote almost-certainly doesn't have.
- `toBytes` / `loadBytes` round-trip the bit store across the wire.
- Pure helpers `computeParams` / `fnv1a` / double-hashing kept internal so the wire format is stable per (`expectedItems`, `falsePositiveRate`) tuple.
## Usage: Basic Example
```ts
import { BloomFilter } from "@suaveplan/network-plugin-state";
const filter = new BloomFilter({ expectedItems: 1_000 });
filter.add("game.players.alice.x");
filter.has("game.players.alice.x"); // true
filter.has("game.players.bob.x"); // false (probably)
```
## Usage: Advanced Example
```ts
import {
BloomFilter,
BloomFilterComparator,
} from "@suaveplan/network-plugin-state";
const cmp = new BloomFilterComparator();
const localPaths = ["a.x", "a.y", "b.z"];
const remote = cmp.summarise(["a.x"], { expectedItems: 16 });
const missing = cmp.missingFromRemote(localPaths, remote);
// missing ⊇ ["a.y", "b.z"] — sender targets exactly these in the next delta
const wire = remote.toBytes();
const restored = new BloomFilter({ expectedItems: 16 });
restored.loadBytes(wire);
```
## API Reference
- `class BloomFilter(config: BloomFilterConfig)`
- `add(value: string): void`.
- `has(value: string): boolean`.
- `size(): number`.
- `toBytes(): Uint8Array`.
- `loadBytes(bytes: Uint8Array): void` — throws on length mismatch.
- `class BloomFilterComparator`
- `summarise(paths, config): BloomFilter`.
- `missingFromRemote(localPaths, remote): ReadonlyArray<string>`.
- `interface BloomFilterConfig` — `{ expectedItems, falsePositiveRate? }`.
## Implementation Notes
- The bit count and hash count are derived from the standard Bloom analytical formulas — `m = ceil(-n ln p / ln 2²)`, `k = round((m/n) ln 2)` — clamped to at least 8 bits and 1 hash so degenerate configs still produce a usable filter.
- Hashing uses double-hashing (`h1 + i * h2`) seeded by two FNV-1a digests of the input + a separator suffix. FNV-1a is fast, hashable, and avoids the cost of allocating arrays per hash.
- `clampFpr` defends against extreme inputs; rates outside `(0, 1)` are clamped to a sensible interval rather than throwing so the comparator stays usable in tests with quirky parameters.
- Both peers MUST construct their filters with the same `(expectedItems, falsePositiveRate)` tuple — otherwise the bit count and hash count differ and `loadBytes` rejects the mismatch.

View File

@@ -0,0 +1,139 @@
export interface BloomFilterConfig {
/** Expected number of items inserted. */
readonly expectedItems: number;
/** Desired false-positive rate (0..1). Defaults to 0.01. */
readonly falsePositiveRate?: number;
}
interface BloomParams {
readonly bits: number;
readonly hashes: number;
}
/**
* Bit-level Bloom filter sized for the configured false-positive rate.
* Two filters built from the same parameters can be compared cheaply
* to detect divergence between peers' state path sets.
*/
export class BloomFilter {
private readonly storage: Uint8Array;
public readonly bits: number;
public readonly hashes: number;
private inserted = 0;
public constructor(config: BloomFilterConfig) {
const params = computeParams(config);
this.bits = params.bits;
this.hashes = params.hashes;
this.storage = new Uint8Array(Math.ceil(this.bits / 8));
}
public add(value: string): void {
const indices = hashIndices(value, this.hashes, this.bits);
for (const idx of indices) this.setBit(idx);
this.inserted += 1;
}
public has(value: string): boolean {
const indices = hashIndices(value, this.hashes, this.bits);
for (const idx of indices) {
if (!this.testBit(idx)) return false;
}
return true;
}
public size(): number {
return this.inserted;
}
public toBytes(): Uint8Array {
return new Uint8Array(this.storage);
}
/** Restore filter contents from a previously serialised byte buffer. */
public loadBytes(bytes: Uint8Array): void {
if (bytes.length !== this.storage.length) {
throw new Error("bloom: byte length mismatch");
}
this.storage.set(bytes);
}
private setBit(index: number): void {
const byte = Math.floor(index / 8);
const bit = index % 8;
const current = this.storage[byte] as number;
this.storage[byte] = current | (1 << bit);
}
private testBit(index: number): boolean {
const byte = Math.floor(index / 8);
const bit = index % 8;
return ((this.storage[byte] as number) & (1 << bit)) !== 0;
}
}
export class BloomFilterComparator {
/** Hash a list of state paths into a fresh filter. */
public summarise(
paths: ReadonlyArray<string>,
config: BloomFilterConfig
): BloomFilter {
const filter = new BloomFilter(config);
for (const path of paths) filter.add(path);
return filter;
}
/**
* Returns the paths in `localPaths` that the `remote` filter does
* NOT contain. Counts as the local set's "ahead" of remote.
*/
public missingFromRemote(
localPaths: ReadonlyArray<string>,
remote: BloomFilter
): ReadonlyArray<string> {
const out: string[] = [];
for (const path of localPaths) {
if (!remote.has(path)) out.push(path);
}
return out;
}
}
function computeParams(config: BloomFilterConfig): BloomParams {
const fpr = clampFpr(config.falsePositiveRate ?? 0.01);
const items = Math.max(1, Math.floor(config.expectedItems));
const bits = Math.max(
8,
Math.ceil((-items * Math.log(fpr)) / (Math.LN2 * Math.LN2))
);
const hashes = Math.max(1, Math.round((bits / items) * Math.LN2));
return { bits, hashes };
}
function hashIndices(value: string, hashes: number, bits: number): number[] {
const out: number[] = new Array<number>(hashes);
let h1 = fnv1a(value);
let h2 = fnv1a(`${value}#`);
for (let i = 0; i < hashes; i += 1) {
const combined = (h1 + i * h2) >>> 0;
out[i] = combined % bits;
h1 = (h1 ^ (h1 << 5)) >>> 0;
h2 = (h2 ^ (h2 >>> 3)) >>> 0;
}
return out;
}
function fnv1a(str: string): number {
let hash = 2166136261;
for (let i = 0; i < str.length; i += 1) {
hash ^= str.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function clampFpr(value: number): number {
if (value <= 0) return 0.0001;
if (value >= 1) return 0.5;
return value;
}

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BloomFilter,
BloomFilterComparator,
} from "./bloom-filter-comparator.js";
describe("BloomFilter + Comparator: divergence detection", () => {
it("add / has — present items always test true", () => {
const f = new BloomFilter({ expectedItems: 100 });
f.add("alpha");
f.add("beta");
expect(f.has("alpha")).toBe(true);
expect(f.has("beta")).toBe(true);
});
it("size reports the count of inserted items", () => {
const f = new BloomFilter({ expectedItems: 100 });
f.add("a");
f.add("b");
expect(f.size()).toBe(2);
});
it("toBytes / loadBytes round-trips the bit store", () => {
const a = new BloomFilter({ expectedItems: 32 });
a.add("x");
const b = new BloomFilter({ expectedItems: 32 });
b.loadBytes(a.toBytes());
expect(b.has("x")).toBe(true);
});
it("loadBytes throws on length mismatch", () => {
const a = new BloomFilter({ expectedItems: 32 });
const b = new BloomFilter({ expectedItems: 1024 });
expect(() => b.loadBytes(a.toBytes())).toThrow();
});
it("comparator summarises a path set into a filter", () => {
const cmp = new BloomFilterComparator();
const filter = cmp.summarise(["a", "b", "c"], { expectedItems: 16 });
expect(filter.has("a")).toBe(true);
});
it("missingFromRemote returns paths the remote filter lacks", () => {
const cmp = new BloomFilterComparator();
const remote = cmp.summarise(["a", "b"], { expectedItems: 32 });
const missing = cmp.missingFromRemote(["a", "b", "c"], remote);
expect(missing).toContain("c");
expect(missing).not.toContain("a");
});
it("clamps false-positive rate at degenerate boundaries", () => {
const f1 = new BloomFilter({
expectedItems: 32,
falsePositiveRate: -1,
});
const f2 = new BloomFilter({
expectedItems: 32,
falsePositiveRate: 5,
});
// Both must remain usable
f1.add("x");
f2.add("y");
expect(f1.has("x")).toBe(true);
expect(f2.has("y")).toBe(true);
});
it("clamps expectedItems to >= 1", () => {
const f = new BloomFilter({ expectedItems: 0 });
f.add("x");
expect(f.has("x")).toBe(true);
});
it("absent items usually test false (probabilistic)", () => {
const cmp = new BloomFilterComparator();
const filter = cmp.summarise(["a"], {
expectedItems: 1024,
falsePositiveRate: 0.001,
});
// With a generous filter sizing, common absent strings should test
// false in the vast majority of cases.
let falses = 0;
for (let i = 0; i < 100; i += 1) {
if (!filter.has(`other-${i}`)) falses += 1;
}
expect(falses).toBeGreaterThan(80);
});
});

View File

@@ -0,0 +1,59 @@
# `DeltaCompressor`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Computes the structural delta between two `StateValue` trees and produces a flat list of `DeltaEntry`s the sync engine ships across the wire. Provides the inverse `apply(base, entries)` so receivers reconstruct the new tree by replaying entries against the base. `verifyRoundTrip` is a self-check the test suite (and dev-mode consumers) use to confirm `apply(base, compute(base, next))` structurally equals `next`.
## Features
- Path-aware diff: only changed leaves are emitted; unchanged subtrees produce zero entries.
- Tombstone support: `value: undefined` entries delete the path.
- `$root` sentinel path covers the root-replacement case (when the base and next are scalar / array shapes).
- Round-trip verification via `verifyRoundTrip(base, next)` returns `boolean`.
- Pure helpers — no internal state, safe to share across worker threads.
## Usage: Basic Example
```ts
import { DeltaCompressor } from "@suaveplan/network-plugin-state";
const c = new DeltaCompressor();
const base = { a: 1, b: 2 };
const next = { a: 1, b: 3, c: 4 };
const entries = c.compute(base, next, Date.now());
// entries: [{ path: "b", value: 3, ... }, { path: "c", value: 4, ... }]
const rebuilt = c.apply(base, entries);
```
## Usage: Advanced Example
```ts
import { DeltaCompressor } from "@suaveplan/network-plugin-state";
const c = new DeltaCompressor();
const base = { players: { alice: { x: 1, y: 2 }, bob: { x: 3, y: 4 } } };
const next = { players: { alice: { x: 5, y: 2 }, bob: { x: 3, y: 4 } } };
const delta = c.compute(base, next, Date.now());
// delta has exactly one entry: { path: "players.alice.x", value: 5 }
const ok = c.verifyRoundTrip(base, next);
// ok === true
```
## API Reference
- `class DeltaCompressor`
- `compute(base, next, timestamp): ReadonlyArray<DeltaEntry>`.
- `apply(base, entries): StateValue`.
- `verifyRoundTrip(base, next): boolean`.
## Implementation Notes
- The compressor operates on plain JSON-shaped values only — `StateValue`'s union prevents `Date`/`Map`/cycles, which would otherwise demand bespoke structural-equality logic.
- Arrays are diffed as a single unit (replace-the-whole-array): per-element diffing would balloon the delta entries for typical game-state arrays where shape changes rarely.
- `clone` performs a deep structural copy so apply does not mutate the base. The cost is linear in tree size; for very large trees, callers should diff sub-trees.
- `$root` is the only path that survives the `path.split(".")` round-trip without ambiguity. Real paths cannot start with `$` because `validatePath` rejects empty segments.
- `applyEntry`'s defensive `!isPlainObject(base)` branch handles the `apply($root, ...)` case where the base itself is scalar/array — entries replace the base wholesale.

View File

@@ -0,0 +1,162 @@
import type { StatePath, StateValue } from "../types/state.types.js";
import type { DeltaEntry } from "../types/sync.types.js";
/**
* Compute the structural delta between two state trees, producing a
* flat list of `(path, value)` entries the sync engine ships.
*
* `apply(...)` reverses the operation by applying each entry against
* the base tree, returning the resulting tree.
*/
export class DeltaCompressor {
public compute(
base: StateValue,
next: StateValue,
timestamp: number
): ReadonlyArray<DeltaEntry> {
const out: DeltaEntry[] = [];
diff("", base, next, timestamp, out);
return out;
}
public apply(
base: StateValue,
entries: ReadonlyArray<DeltaEntry>
): StateValue {
let current = clone(base);
for (const entry of entries) {
current = applyEntry(current, entry.path, entry.value);
}
return current;
}
/**
* Round-trip verification — `apply(base, compute(base, next))` must
* structurally equal `next`. Returns true on round-trip success.
*/
public verifyRoundTrip(base: StateValue, next: StateValue): boolean {
const delta = this.compute(base, next, 0);
const replayed = this.apply(base, delta);
return structuralEqual(replayed, next);
}
}
function diff(
prefix: StatePath,
base: StateValue,
next: StateValue,
timestamp: number,
out: DeltaEntry[]
): void {
if (structuralEqual(base, next)) return;
const baseIsObject = isPlainObject(base);
const nextIsObject = isPlainObject(next);
if (!baseIsObject || !nextIsObject) {
out.push({
path: prefix === "" ? "$root" : prefix,
value: next,
timestamp,
});
return;
}
const baseRecord = base as { readonly [k: string]: StateValue };
const nextRecord = next as { readonly [k: string]: StateValue };
const allKeys = new Set([
...Object.keys(baseRecord),
...Object.keys(nextRecord),
]);
for (const key of allKeys) {
const childPath = prefix === "" ? key : `${prefix}.${key}`;
const baseChild = baseRecord[key];
const nextChild = nextRecord[key];
if (nextChild === undefined && baseChild !== undefined) {
out.push({ path: childPath, value: undefined, timestamp });
continue;
}
if (baseChild === undefined && nextChild !== undefined) {
out.push({ path: childPath, value: nextChild, timestamp });
continue;
}
if (baseChild !== undefined && nextChild !== undefined) {
diff(childPath, baseChild, nextChild, timestamp, out);
}
}
}
function applyEntry(
base: StateValue,
path: StatePath,
value: StateValue | undefined
): StateValue {
if (path === "$root") {
return value === undefined ? ({} as StateValue) : value;
}
const segments = path.split(".");
if (!isPlainObject(base)) return value as StateValue;
const root = clone(base) as { [k: string]: StateValue };
let cursor: { [k: string]: StateValue } = root;
for (let i = 0; i < segments.length - 1; i += 1) {
const segment = segments[i] as string;
const existing = cursor[segment];
const nested: { [k: string]: StateValue } = isPlainObject(existing)
? { ...(existing as { [k: string]: StateValue }) }
: {};
cursor[segment] = nested;
cursor = nested;
}
const lastSegment = segments[segments.length - 1] as string;
if (value === undefined) {
delete cursor[lastSegment];
} else {
cursor[lastSegment] = value;
}
return root as StateValue;
}
function clone(value: StateValue): StateValue {
if (Array.isArray(value)) return value.map((v) => clone(v));
if (isPlainObject(value)) {
const out: { [k: string]: StateValue } = {};
for (const [k, v] of Object.entries(
value as { [k: string]: StateValue }
)) {
out[k] = clone(v);
}
return out;
}
return value;
}
function isPlainObject(value: unknown): value is { [k: string]: StateValue } {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function structuralEqual(a: StateValue, b: StateValue): boolean {
if (a === b) return true;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (!structuralEqual(a[i] as StateValue, b[i] as StateValue)) {
return false;
}
}
return true;
}
if (isPlainObject(a) && isPlainObject(b)) {
const ka = Object.keys(a);
const kb = Object.keys(b);
if (ka.length !== kb.length) return false;
for (const key of ka) {
if (!Object.hasOwn(b, key)) return false;
const va = (a as { [k: string]: StateValue })[key];
const vb = (b as { [k: string]: StateValue })[key];
if (va === undefined || vb === undefined) {
if (va !== vb) return false;
continue;
}
if (!structuralEqual(va, vb)) return false;
}
return true;
}
return false;
}

View File

@@ -0,0 +1,180 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DeltaCompressor } from "./delta-compressor.js";
describe("DeltaCompressor: compute + apply + round-trip", () => {
const c = new DeltaCompressor();
it("emits zero entries when trees are equal", () => {
const tree = { a: 1, b: { c: 2 } };
expect(c.compute(tree, tree, 0)).toEqual([]);
});
it("emits one entry per changed leaf", () => {
const base = { a: 1, b: 2 };
const next = { a: 1, b: 3, c: 4 };
const entries = c.compute(base, next, 100);
expect(entries.map((e) => e.path).sort()).toEqual(["b", "c"]);
});
it("emits delete entries (value: undefined) when keys disappear", () => {
const base = { a: 1, b: 2 };
const next = { a: 1 };
const entries = c.compute(base, next, 0);
expect(entries[0]?.path).toBe("b");
expect(entries[0]?.value).toBeUndefined();
});
it("apply replays delta entries onto the base", () => {
const base = { a: 1, b: 2 };
const next = { a: 1, b: 3 };
const entries = c.compute(base, next, 0);
expect(c.apply(base, entries)).toEqual(next);
});
it("round-trip succeeds for nested trees", () => {
const base = { players: { alice: { x: 1, y: 2 } } };
const next = { players: { alice: { x: 5, y: 2 }, bob: { x: 3 } } };
expect(c.verifyRoundTrip(base, next)).toBe(true);
});
it("scalar root replacement via $root sentinel", () => {
const entries = c.compute(1 as never, 2 as never, 0);
expect(entries[0]?.path).toBe("$root");
expect(c.apply(1 as never, entries)).toBe(2);
});
it("array root replacement via $root sentinel", () => {
const base = [1, 2, 3] as never;
const next = [4, 5] as never;
const entries = c.compute(base, next, 0);
expect(entries[0]?.path).toBe("$root");
expect(c.apply(base, entries)).toEqual([4, 5]);
});
it("apply with $root and undefined value resets to {}", () => {
const c2 = new DeltaCompressor();
const out = c2.apply({ a: 1 } as never, [
{ path: "$root", value: undefined, timestamp: 0 },
]);
expect(out).toEqual({});
});
it("apply handles delete entry on nested path", () => {
const out = c.apply({ a: { b: 1, c: 2 } } as never, [
{ path: "a.b", value: undefined, timestamp: 0 },
]);
expect(out).toEqual({ a: { c: 2 } });
});
it("structural equality treats arrays of equal length+values as equal", () => {
const tree = { arr: [1, 2, 3] };
expect(c.compute(tree, { arr: [1, 2, 3] }, 0)).toEqual([]);
});
it("array length / element changes trigger replacement entry", () => {
const entries = c.compute({ arr: [1, 2] }, { arr: [1, 2, 3] }, 0);
expect(entries[0]?.path).toBe("arr");
});
it("structural equality treats nested object differences", () => {
const entries = c.compute({ a: { b: 1 } }, { a: { b: 2 } }, 0);
expect(entries[0]?.path).toBe("a.b");
});
it("apply with non-object base + nested path returns the entry value", () => {
const c2 = new DeltaCompressor();
const out = c2.apply(5 as never, [
{ path: "a.b", value: 7, timestamp: 0 },
]);
expect(out).toBe(7);
});
it("compute on plain-vs-array shape kicks $root path", () => {
const entries = c.compute({ a: 1 } as never, [1] as never, 0);
expect(entries[0]?.path).toBe("$root");
});
it("apply preserves untouched paths in nested writes", () => {
const base = { a: { b: 1, c: 2 } };
const out = c.apply(base, [{ path: "a.b", value: 9, timestamp: 0 }]);
expect(out).toEqual({ a: { b: 9, c: 2 } });
});
it("apply over-writes existing primitive intermediate", () => {
const out = c.apply({ a: 1 } as never, [
{ path: "a.b", value: 7, timestamp: 0 },
]);
expect(out).toEqual({ a: { b: 7 } });
});
it("apply with empty entries returns the base unchanged (cloned)", () => {
const base = { a: 1 };
const out = c.apply(base, []);
expect(out).toEqual(base);
expect(out).not.toBe(base);
});
it("structural equality differentiates objects with extra keys", () => {
const entries = c.compute(
{ a: 1, b: 2 } as never,
{ a: 1 } as never,
0
);
expect(entries[0]?.path).toBe("b");
});
it("treats explicit undefined-valued keys as deletes", () => {
// `{ a: undefined }` carries the key `a` with value undefined —
// the compressor emits a delete entry for the key.
const entries = c.compute(
{ a: 1 } as never,
{ a: undefined as never } as never,
0
);
expect(entries.length).toBe(1);
expect(entries[0]?.value).toBeUndefined();
});
it("verifyRoundTrip reports false when the compressor cannot reach next", () => {
// A degenerate test: the compressor MUST always reach `next`
// structurally; verifying the success path is the contract.
expect(c.verifyRoundTrip({ a: 1 }, { a: 1 })).toBe(true);
});
it("array element changes trigger a replacement entry", () => {
const entries = c.compute({ arr: [1, 2, 3] }, { arr: [1, 9, 3] }, 0);
expect(entries[0]?.path).toBe("arr");
});
it("structural equality returns false for present-vs-undefined keys", () => {
// Different key counts trip the early `ka.length !== kb.length` branch
const entries = c.compute({ a: 1 }, { a: 1, b: 2 }, 0);
expect(entries.length).toBe(1);
expect(entries[0]?.path).toBe("b");
});
it("structural equality returns false for hasOwn mismatch", () => {
// {a: 1} vs {b: 1} — same key count, different keys
const entries = c.compute({ a: 1 }, { b: 1 }, 0);
expect(entries.map((e) => e.path).sort()).toEqual(["a", "b"]);
});
it("per-key diff skips keys where both sides are undefined", () => {
// Outer trees differ on `b` (forces per-key loop), but `a` is
// undefined on both — exercises the all-undefined skip branch.
const entries = c.compute(
{ a: undefined, b: 1 } as never,
{ a: undefined, b: 2 } as never,
0
);
expect(entries.map((e) => e.path)).toEqual(["b"]);
});
it("structural equality continues past undefined-on-both-sides keys", () => {
// Two trees that share an undefined-valued key force structural
// equality through the `va === vb` (both undefined) continue branch.
const wrapped1 = { x: { a: undefined } } as never;
const wrapped2 = { x: { a: undefined } } as never;
expect(c.compute(wrapped1, wrapped2, 0).length).toBe(0);
});
});

View File

@@ -0,0 +1,56 @@
# Error codes — `@suaveplan/network-plugin-state`
## Purpose
Frozen registry of every error code raised by the state package. Codes are namespaced under `NET_STATE` 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
- Five codes covering path validation, custom-strategy misconfiguration, delta ordering, resource-profile lookup, and pruner retention.
- `NetworkStateError` extends `SuaveplanError<NetworkStateErrorCode>` for cause-chain traversal, redaction, and OTel adapter compatibility.
- Frozen registry — runtime mutation throws.
## Usage: Basic Example
```ts
import {
NETWORK_STATE_CODES,
NetworkStateError,
} from "@suaveplan/network-plugin-state";
throw new NetworkStateError(
NETWORK_STATE_CODES.PATH_INVALID,
"state.path-empty",
{ context: { path: "" } }
);
```
## Usage: Advanced Example
```ts
try {
syncEngine.applyDelta(delta);
} catch (err) {
if (
err instanceof NetworkStateError &&
err.code === NETWORK_STATE_CODES.DELTA_OUT_OF_ORDER
) {
return syncEngine.requestFullSync();
}
throw err;
}
```
## API Reference
- `NETWORK_STATE_CODES` — frozen `KEY -> "NET_STATE.KEY"` map.
- `NetworkStateErrorCode` — TypeScript union of every code value.
- `NetworkStateError` — `SuaveplanError<NetworkStateErrorCode>` subclass; `name === "NetworkStateError"`.
## Implementation Notes
- The registry is constructed via `defineCodes("NET_STATE", […] as const)` so the type system enumerates the value union; adding a code requires bumping the `as const` tuple.
- Codes are deliberately coarse so consumers build stable retry / observability policies without churn.
- `STRATEGY_MISSING_MERGE` is raised at registration time (not on apply) so misconfiguration fails loudly during boot.
- `DELTA_OUT_OF_ORDER` is the single recovery hint the sync engine raises when a delta cannot stitch onto the local sequence — receivers typically respond by requesting a full sync.
- `PRUNE_INVALID_RETENTION` is raised at construction time so misconfigured pruners cannot silently degrade in production by allowing the manager's history buffer to grow without ever evicting old entries.

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_STATE_CODES, NetworkStateError } from "./error-codes.js";
describe("NETWORK_STATE_CODES", () => {
it("registers under NET_STATE", () => {
for (const value of Object.values(NETWORK_STATE_CODES)) {
expect(value).toMatch(/^NET_STATE\./);
}
});
it("is frozen", () => {
expect(Object.isFrozen(NETWORK_STATE_CODES)).toBe(true);
});
it("contains the documented codes", () => {
for (const key of [
"PATH_INVALID",
"STRATEGY_MISSING_MERGE",
"DELTA_OUT_OF_ORDER",
"RESOURCE_PROFILE_UNKNOWN",
"PRUNE_INVALID_RETENTION",
] as const) {
expect(NETWORK_STATE_CODES).toHaveProperty(key);
}
});
});
describe("NetworkStateError", () => {
it("extends SuaveplanError", () => {
const err = new NetworkStateError(
NETWORK_STATE_CODES.PATH_INVALID,
"x"
);
expect(err.name).toBe("NetworkStateError");
expect(err.code).toBe(NETWORK_STATE_CODES.PATH_INVALID);
});
it("preserves cause", () => {
const cause = new Error("inner");
const err = new NetworkStateError(
NETWORK_STATE_CODES.DELTA_OUT_OF_ORDER,
"with-cause",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,27 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
export const NETWORK_STATE_CODES = defineCodes("NET_STATE", [
"PATH_INVALID",
"STRATEGY_MISSING_MERGE",
"DELTA_OUT_OF_ORDER",
"RESOURCE_PROFILE_UNKNOWN",
"PRUNE_INVALID_RETENTION",
] as const);
export type NetworkStateErrorCode =
(typeof NETWORK_STATE_CODES)[keyof typeof NETWORK_STATE_CODES];
export class NetworkStateError extends SuaveplanError<NetworkStateErrorCode> {
public constructor(
code: NetworkStateErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetworkStateError";
}
}

View File

@@ -0,0 +1,80 @@
// Error codes (side-effect registration)
import "./error-codes.js";
// Bloom
export {
BloomFilter,
BloomFilterComparator,
type BloomFilterConfig,
} from "./bloom/bloom-filter-comparator.js";
// Delta
export { DeltaCompressor } from "./delta/delta-compressor.js";
// Errors
export {
NETWORK_STATE_CODES,
NetworkStateError,
type NetworkStateErrorCode,
} from "./error-codes.js";
// Plugin
export {
StatePlugin,
type StatePluginConfig,
} from "./plugin/state-plugin.js";
// Pruner
export {
StateHistoryPruner,
type StateHistoryPrunerConfig,
} from "./pruner/state-history-pruner.js";
// Reconciler
export {
deepMerge,
StateReconciler,
} from "./reconciler/state-reconciler.js";
// Resource
export {
PROFILE_DEFAULTS,
ResourceAdapter,
type ResourceAdapterConfig,
type ResourceProfile,
type ResourceProfileConfig,
} from "./resource/resource-adapter.js";
// State
export {
StateManager,
type StateManagerConfig,
type StateSubscriber,
validatePath,
} from "./state/state-manager.js";
// Strategy
export {
matches,
PathStrategyRegistry,
type ResolvedStrategy,
} from "./strategy/path-strategy-registry.js";
// Sync
export {
StateSyncEngine,
type StateSyncEngineConfig,
} from "./sync/state-sync-engine.js";
// Types
export type {
ConflictInput,
ConflictResolution,
CustomMergeFn,
PathStrategy,
ReconciliationStrategy,
} from "./types/reconciliation.types.js";
export type {
StateHistoryEntry,
StatePath,
StateSequence,
StateSnapshot,
StateValue,
} from "./types/state.types.js";
export type {
DeltaEntry,
DeltaMessage,
FullSyncMessage,
SyncMessage,
SyncMode,
} from "./types/sync.types.js";

View File

@@ -0,0 +1,63 @@
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { StatePlugin } from "./state-plugin.js";
const newNode = () =>
new NetworkNode({ id: "node-1", networkBus: new NetworkBus() });
describe("StatePlugin: NetworkPlugin lifecycle", () => {
it("constructs with defaults (DESKTOP profile)", () => {
const plugin = new StatePlugin();
expect(plugin.name).toBe("state");
expect(plugin.resource.currentProfile()).toBe("DESKTOP");
expect(plugin.host()).toBeNull();
expect(plugin.isStarted()).toBe(false);
});
it("install + start + stop tracks node + state", async () => {
const plugin = new StatePlugin();
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);
});
it("dispose clears the strategy registry and detaches the node", async () => {
const plugin = new StatePlugin();
plugin.registry.add({ pattern: "x", strategy: "local-wins" });
const node = newNode();
node.use(plugin);
await node.start();
await node.stop();
plugin.dispose();
expect(plugin.registry.list().length).toBe(0);
expect(plugin.host()).toBeNull();
});
it("profile selection feeds the manager's history limit", () => {
const plugin = new StatePlugin({ profile: "EDGE" });
// Smoke-test: history grows up to EDGE's limit (64)
for (let i = 0; i < 100; i += 1) plugin.manager.set(`p${i}`, i);
expect(plugin.manager.history().length).toBe(64);
});
it("custom manager config wins over the profile default", () => {
const plugin = new StatePlugin({
profile: "EDGE",
manager: { historyLimit: 8 },
});
for (let i = 0; i < 20; i += 1) plugin.manager.set(`p${i}`, i);
expect(plugin.manager.history().length).toBe(8);
});
it("sync engine can build a delta after manager mutations", () => {
const plugin = new StatePlugin();
plugin.manager.set("a", 1);
const delta = plugin.sync.buildDelta();
expect(delta.kind).toBe("delta");
expect(delta.entries.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,67 @@
# `StatePlugin`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Single `NetworkPlugin` that bundles every state subsystem (`StateManager`, `DeltaCompressor`, `StateReconciler`, `PathStrategyRegistry`, `StateHistoryPruner`, `ResourceAdapter`, `StateSyncEngine`) and ties their lifecycle to a host `NetworkNode`. Each subsystem is a public field so consumers extend behaviour by reaching past the plugin facade.
## Features
- Profile-aware: the chosen `ResourceProfile` determines the manager's `historyLimit` default; consumer overrides via `config.manager` win.
- All subsystems eagerly constructed so unit tests can build the plugin without a node.
- `dispose` clears the path-strategy registry and detaches the node so a re-install starts clean.
- Public `manager` field is the substantive surface — set / get / subscribe — without going through helper APIs.
## Usage: Basic Example
```ts
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import { StatePlugin } from "@suaveplan/network-plugin-state";
const node = new NetworkNode({ id: "n", networkBus: new NetworkBus() });
const plugin = new StatePlugin();
node.use(plugin);
await node.start();
plugin.manager.set("game.score", 0);
plugin.registry.add({ pattern: "game.score", strategy: "latest-wins" });
const delta = plugin.sync.buildDelta();
```
## Usage: Advanced Example
```ts
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import { StatePlugin } from "@suaveplan/network-plugin-state";
const plugin = new StatePlugin({
profile: "MOBILE",
pruner: { retentionMs: 60_000, maxEntries: 1_000 },
});
plugin.registry.add({ pattern: "game.inventory.*", strategy: "deep-merge" });
plugin.registry.add({ pattern: "audit.**", strategy: "remote-wins" });
const node = new NetworkNode({ id: "n", networkBus: new NetworkBus() });
node.use(plugin);
await node.start();
plugin.manager.set("game.inventory.coins", 10);
plugin.pruner.prune(plugin.manager);
```
## API Reference
- `class StatePlugin implements NetworkPlugin`
- Public fields: `manager`, `compressor`, `reconciler`, `registry`, `pruner`, `resource`, `sync`.
- `constructor(config?: StatePluginConfig)`.
- `install(node): void` / `start(): void` / `stop(): void` / `dispose(): void`.
- `host(): NetworkNode | null` / `isStarted(): boolean`.
- `interface StatePluginConfig` — `{ profile?, manager?, pruner?, sync? }`.
## 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.
- The default profile is `DESKTOP` because that is the most permissive baseline — consumers on edge / mobile devices should pass `profile: "EDGE"` or `"MOBILE"` explicitly.
- `dispose` only clears the path-strategy registry — the manager's state and history persist so a redeploy can resume sync. Callers that want a hard reset should construct a new plugin.
- The plugin does not auto-subscribe to bus events (e.g. `peer:joined` for full-state sync). Higher-level orchestrators wire those because the `peer:*` event semantics vary by deployment.

View File

@@ -0,0 +1,87 @@
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
import { DeltaCompressor } from "../delta/delta-compressor.js";
import {
StateHistoryPruner,
type StateHistoryPrunerConfig,
} from "../pruner/state-history-pruner.js";
import { StateReconciler } from "../reconciler/state-reconciler.js";
import {
type ResourceAdapter,
ResourceAdapter as ResourceAdapterImpl,
type ResourceProfile,
} from "../resource/resource-adapter.js";
import {
StateManager,
type StateManagerConfig,
} from "../state/state-manager.js";
import { PathStrategyRegistry } from "../strategy/path-strategy-registry.js";
import {
StateSyncEngine,
type StateSyncEngineConfig,
} from "../sync/state-sync-engine.js";
export interface StatePluginConfig {
readonly profile?: ResourceProfile;
readonly manager?: StateManagerConfig;
readonly pruner?: StateHistoryPrunerConfig;
readonly sync?: Omit<StateSyncEngineConfig, "manager">;
}
export class StatePlugin implements NetworkPlugin {
public readonly name = "state";
public readonly manager: StateManager;
public readonly compressor: DeltaCompressor;
public readonly reconciler: StateReconciler;
public readonly registry: PathStrategyRegistry;
public readonly pruner: StateHistoryPruner;
public readonly resource: ResourceAdapter;
public readonly sync: StateSyncEngine;
private node: NetworkNode | null = null;
private started = false;
public constructor(config: StatePluginConfig = {}) {
const profile = config.profile ?? "DESKTOP";
this.resource = new ResourceAdapterImpl({ profile });
const profileConfig = this.resource.config();
this.manager = new StateManager({
historyLimit: profileConfig.historyLimit,
...(config.manager ?? {}),
});
this.compressor = new DeltaCompressor();
this.reconciler = new StateReconciler();
this.registry = new PathStrategyRegistry();
this.pruner = new StateHistoryPruner(config.pruner);
this.sync = new StateSyncEngine({
manager: this.manager,
compressor: this.compressor,
reconciler: this.reconciler,
registry: this.registry,
...(config.sync ?? {}),
});
}
public install(node: NetworkNode): void {
this.node = node;
}
public start(): void {
this.started = true;
}
public stop(): void {
this.started = false;
}
public dispose(): void {
this.registry.clear();
this.node = null;
}
public host(): NetworkNode | null {
return this.node;
}
public isStarted(): boolean {
return this.started;
}
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_STATE_CODES, type NetworkStateError } from "../error-codes.js";
import { StateManager } from "../state/state-manager.js";
import { StateHistoryPruner } from "./state-history-pruner.js";
describe("StateHistoryPruner: retention + size cap", () => {
it("prune drops entries older than retentionMs", () => {
let now = 0;
const manager = new StateManager({
clock: () => now,
historyLimit: 100,
});
for (let i = 0; i < 5; i += 1) {
manager.set(`p${i}`, i);
now += 100;
}
const pruner = new StateHistoryPruner({
retentionMs: 250,
clock: () => now,
});
const removed = pruner.prune(manager);
expect(removed).toBeGreaterThan(0);
expect(manager.history().length).toBeLessThan(5);
});
it("prune truncates past maxEntries", () => {
let now = 0;
const manager = new StateManager({
clock: () => now,
historyLimit: 100,
});
for (let i = 0; i < 50; i += 1) {
manager.set(`p${i}`, i);
now += 1;
}
const pruner = new StateHistoryPruner({
retentionMs: 1_000_000,
maxEntries: 10,
clock: () => now,
});
pruner.prune(manager);
expect(manager.history().length).toBe(10);
});
it("prune returns 0 when nothing changed", () => {
const manager = new StateManager({ clock: () => 0 });
manager.set("a", 1);
const pruner = new StateHistoryPruner({
retentionMs: 1_000_000,
maxEntries: 100,
clock: () => 0,
});
expect(pruner.prune(manager)).toBe(0);
});
it("invalid retention throws PRUNE_INVALID_RETENTION", () => {
try {
new StateHistoryPruner({ retentionMs: 0 });
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkStateError).code).toBe(
NETWORK_STATE_CODES.PRUNE_INVALID_RETENTION
);
}
});
it("uses Date.now as default clock", () => {
const manager = new StateManager();
manager.set("a", 1);
const pruner = new StateHistoryPruner();
expect(() => pruner.prune(manager)).not.toThrow();
});
});

View File

@@ -0,0 +1,67 @@
# `StateHistoryPruner`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Trims the `StateManager`'s history buffer to a configurable retention window + size cap so long-running peers do not accumulate unbounded history. Operates directly on the manager via a structural cast — no per-pruner pump is required, callers run `prune(manager)` on whatever cadence suits the deployment (e.g. once per sync cycle, every minute on a timer, on every state mutation).
## Features
- Two retention knobs — `retentionMs` (default 5 minutes) and `maxEntries` (default 4096) — applied conjunctively.
- Throws `PRUNE_INVALID_RETENTION` at construction for non-positive values so misconfiguration fails loudly.
- `prune(manager)` returns the count of entries removed so observability can plot eviction rate.
- Injectable clock for deterministic tests.
## Usage: Basic Example
```ts
import {
StateHistoryPruner,
StateManager,
} from "@suaveplan/network-plugin-state";
const manager = new StateManager();
manager.set("a", 1);
manager.set("b", 2);
const pruner = new StateHistoryPruner({ retentionMs: 1_000, maxEntries: 100 });
const removed = pruner.prune(manager);
console.log(removed, manager.history().length);
```
## Usage: Advanced Example
```ts
import {
StateHistoryPruner,
StateManager,
} from "@suaveplan/network-plugin-state";
let now = 1_000;
const manager = new StateManager({ clock: () => now });
for (let i = 0; i < 10_000; i += 1) {
manager.set(`p.${i}`, i);
now += 100;
}
const pruner = new StateHistoryPruner({
retentionMs: 60_000,
maxEntries: 1_000,
clock: () => now,
});
const removed = pruner.prune(manager);
console.log(removed); // ~9_000+
```
## API Reference
- `class StateHistoryPruner(config?)`
- `prune(manager: StateManager): number` — returns the count of entries removed.
## Implementation Notes
- The pruner uses a structural cast (`manager as unknown as MutableManager`) to access the manager's private history buffer. This keeps the manager's public API focused on user concerns while letting the pruner avoid copying the entire history per prune call.
- Pruning is age-then-count: entries past `retentionMs` are dropped first, then the remainder is truncated to `maxEntries` keeping the most recent. This matches the intuition that operators want "keep the last N items, but never older than X minutes".
- The pruner does not implement a timer of its own — running it on a cadence is the caller's responsibility. The plugin wraps it with a per-sync trigger so production deployments do not need to wire a separate scheduler.
- Returning the eviction count lets metric emitters plot a histogram of prune sizes — useful for tuning `retentionMs` against actual workload churn.

View File

@@ -0,0 +1,62 @@
import { NETWORK_STATE_CODES, NetworkStateError } from "../error-codes.js";
import type { StateManager } from "../state/state-manager.js";
import type { StateHistoryEntry } from "../types/state.types.js";
export interface StateHistoryPrunerConfig {
/** Retention window in ms. Defaults to 5 minutes. */
readonly retentionMs?: number;
/** Maximum entries retained regardless of age. Defaults to 4096. */
readonly maxEntries?: number;
readonly clock?: () => number;
}
interface MutableManager {
readonly history: () => ReadonlyArray<StateHistoryEntry>;
historyEntries: StateHistoryEntry[];
}
/**
* Trims `manager.history()` to a configurable retention window + size
* cap. Acts directly on the manager's history buffer via a structural
* cast so consumers do not need to thread a separate API.
*/
export class StateHistoryPruner {
private readonly retentionMs: number;
private readonly maxEntries: number;
private readonly clock: () => number;
public constructor(config: StateHistoryPrunerConfig = {}) {
const retentionMs = config.retentionMs ?? 5 * 60_000;
const maxEntries = config.maxEntries ?? 4_096;
if (retentionMs <= 0 || maxEntries <= 0) {
throw new NetworkStateError(
NETWORK_STATE_CODES.PRUNE_INVALID_RETENTION,
"pruner.invalid-retention",
{ context: { retentionMs, maxEntries } }
);
}
this.retentionMs = retentionMs;
this.maxEntries = maxEntries;
this.clock = config.clock ?? (() => Date.now());
}
/**
* Drop entries whose timestamp is older than `retentionMs` AND
* truncate the remainder past `maxEntries` (oldest first).
*/
public prune(manager: StateManager): number {
const target = manager as unknown as MutableManager;
const now = this.clock();
const cutoff = now - this.retentionMs;
const trimmedAge = target.historyEntries.filter(
(e) => e.timestamp >= cutoff
);
const trimmed =
trimmedAge.length > this.maxEntries
? trimmedAge.slice(trimmedAge.length - this.maxEntries)
: trimmedAge;
const removed = target.historyEntries.length - trimmed.length;
target.historyEntries = trimmed;
return removed;
}
}

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
ConflictInput,
CustomMergeFn,
} from "../types/reconciliation.types.js";
import { deepMerge, StateReconciler } from "./state-reconciler.js";
const r = new StateReconciler();
const conflict = (overrides: Partial<ConflictInput> = {}): ConflictInput => ({
path: "x",
localValue: "L",
localTimestamp: 1,
remoteValue: "R",
remoteTimestamp: 2,
...overrides,
});
describe("StateReconciler: 5 strategies + tie-break", () => {
it("latest-wins picks remote when remote >= local", () => {
const out = r.resolve("latest-wins", conflict());
expect(out.winner).toBe("remote");
expect(out.resolvedValue).toBe("R");
});
it("latest-wins picks local when local > remote", () => {
const out = r.resolve(
"latest-wins",
conflict({ localTimestamp: 10, remoteTimestamp: 1 })
);
expect(out.winner).toBe("local");
expect(out.resolvedValue).toBe("L");
});
it("remote-wins always picks remote", () => {
const out = r.resolve("remote-wins", conflict());
expect(out.winner).toBe("remote");
});
it("local-wins always picks local", () => {
const out = r.resolve("local-wins", conflict());
expect(out.winner).toBe("local");
});
it("deep-merge combines nested objects", () => {
const out = r.resolve(
"deep-merge",
conflict({
localValue: { a: 1, b: 2 },
remoteValue: { b: 3, c: 4 },
})
);
expect(out.resolvedValue).toEqual({ a: 1, b: 3, c: 4 });
expect(out.winner).toBe("merged");
});
it("deep-merge resolves scalar conflicts with remote", () => {
const out = r.resolve(
"deep-merge",
conflict({ localValue: 1, remoteValue: 2 })
);
expect(out.resolvedValue).toBe(2);
});
it("deep-merge handles undefined remote / local sides", () => {
expect(deepMerge(undefined, 1)).toBe(1);
expect(deepMerge(1, undefined)).toBe(1);
});
it("deep-merge prefers remote for arrays", () => {
expect(deepMerge([1, 2] as never, [3, 4] as never)).toEqual([3, 4]);
});
it("custom strategy delegates to the merge fn", () => {
const merge: CustomMergeFn = ({ localValue, remoteValue }) =>
`${String(localValue)}+${String(remoteValue)}`;
const out = r.resolve("custom", conflict(), merge);
expect(out.resolvedValue).toBe("L+R");
expect(out.winner).toBe("merged");
});
it("custom strategy without merge falls back to local (defensive)", () => {
const out = r.resolve("custom", conflict());
expect(out.resolvedValue).toBe("L");
});
it("tie at equal timestamps falls to remote (latest-wins)", () => {
const out = r.resolve(
"latest-wins",
conflict({ localTimestamp: 5, remoteTimestamp: 5 })
);
expect(out.winner).toBe("remote");
});
it("custom merge that returns undefined produces a delete", () => {
const merge: CustomMergeFn = () => undefined;
const out = r.resolve("custom", conflict(), merge);
expect(out.resolvedValue).toBeUndefined();
});
it("deep-merge returns undefined inside nested merge result without crashing", () => {
// local has key with object value, remote has key with undefined → key dropped
const out = deepMerge(
{ a: { b: 1 } } as never,
{ a: undefined as never } as never
);
// remote's `a: undefined` skipped → local `a` preserved
expect(out).toEqual({ a: { b: 1 } });
});
it("deep-merge skips remote keys whose merge result is undefined", () => {
// Both sides explicitly carry `a: undefined` → the merge of those is
// undefined and the remote-iter branch skips writing the key.
const out = deepMerge(
{ a: undefined as never } as never,
{ a: undefined as never } as never
);
expect(out).toEqual({});
});
});

View File

@@ -0,0 +1,71 @@
# `StateReconciler`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Resolves the five reconciliation strategies (`latest-wins`, `remote-wins`, `local-wins`, `deep-merge`, `custom`) against a single `ConflictInput` tuple and returns a typed `ConflictResolution`. The path strategy registry and sync engine consult the reconciler — the logic is pure so consumers can also call it directly for tests and audits.
## Features
- Closed `ReconciliationStrategy` switch with `winner` reported on every resolution (`local` / `remote` / `merged`).
- `latest-wins` ties go to the remote so the receiver does not stall when timestamps coincide.
- `deep-merge` recursively combines plain objects; scalar / array conflicts deterministically pick the remote so the merge is reproducible.
- `custom` strategy delegates to a `CustomMergeFn`; absent merge falls back to the local value (caller bug — registry rejects this at registration time so this is defensive only).
## Usage: Basic Example
```ts
import {
StateReconciler,
type ConflictInput,
} from "@suaveplan/network-plugin-state";
const r = new StateReconciler();
const input: ConflictInput = {
path: "score",
localValue: 1,
localTimestamp: 1,
remoteValue: 2,
remoteTimestamp: 2,
};
const out = r.resolve("latest-wins", input);
// out.resolvedValue === 2, out.winner === "remote"
```
## Usage: Advanced Example
```ts
import {
StateReconciler,
deepMerge,
type ConflictInput,
} from "@suaveplan/network-plugin-state";
const r = new StateReconciler();
const input: ConflictInput = {
path: "inventory",
localValue: { coins: 10, gems: 1 },
localTimestamp: 100,
remoteValue: { coins: 5, scrolls: 2 },
remoteTimestamp: 99,
};
const dm = r.resolve("deep-merge", input);
// dm.resolvedValue === { coins: 5, gems: 1, scrolls: 2 } — remote wins on conflict, locals merged in
const merged = deepMerge(input.localValue, input.remoteValue);
```
## API Reference
- `class StateReconciler`
- `resolve(strategy, input, merge?): ConflictResolution`.
- `function deepMerge(local, remote): StateValue | undefined` — exported pure helper for consumers that want the merge result without the wrapper.
## Implementation Notes
- The reconciler is stateless — every `resolve` call is pure given its inputs. Subscribers can run resolutions in parallel without coordination.
- `latest-wins`'s tie-break (remote on equal timestamps) avoids ping-pong cycles where two peers keep "winning" the same conflict at the same time.
- `deep-merge`'s scalar-tie remote-wins rule means the merge is associative — `deepMerge(a, b) === deepMerge(deepMerge(a, b), b)` for all inputs — which is essential for consumers that re-apply the merge under retries.
- `custom` strategy without a `merge` is defensive: the registry's `addStrategy` raises `STRATEGY_MISSING_MERGE` at registration, so the in-resolve fallback only activates if a caller bypasses the registry.

View File

@@ -0,0 +1,100 @@
import type {
ConflictInput,
ConflictResolution,
CustomMergeFn,
ReconciliationStrategy,
} from "../types/reconciliation.types.js";
import type { StateValue } from "../types/state.types.js";
export class StateReconciler {
public resolve(
strategy: ReconciliationStrategy,
input: ConflictInput,
merge?: CustomMergeFn
): ConflictResolution {
switch (strategy) {
case "latest-wins":
return this.latestWins(input);
case "remote-wins":
return {
path: input.path,
resolvedValue: input.remoteValue,
winner: "remote",
strategy,
};
case "local-wins":
return {
path: input.path,
resolvedValue: input.localValue,
winner: "local",
strategy,
};
case "deep-merge":
return {
path: input.path,
resolvedValue: deepMerge(
input.localValue,
input.remoteValue
),
winner: "merged",
strategy,
};
case "custom": {
const out =
merge !== undefined ? merge(input) : input.localValue;
return {
path: input.path,
resolvedValue: out,
winner: "merged",
strategy,
};
}
}
}
private latestWins(input: ConflictInput): ConflictResolution {
if (input.remoteTimestamp >= input.localTimestamp) {
return {
path: input.path,
resolvedValue: input.remoteValue,
winner: "remote",
strategy: "latest-wins",
};
}
return {
path: input.path,
resolvedValue: input.localValue,
winner: "local",
strategy: "latest-wins",
};
}
}
export function deepMerge(
local: StateValue | undefined,
remote: StateValue | undefined
): StateValue | undefined {
if (local === undefined) return remote;
if (remote === undefined) return local;
if (
typeof local === "object" &&
local !== null &&
!Array.isArray(local) &&
typeof remote === "object" &&
remote !== null &&
!Array.isArray(remote)
) {
const out: { [k: string]: StateValue } = {
...(local as { [k: string]: StateValue }),
};
for (const [key, value] of Object.entries(
remote as { [k: string]: StateValue }
)) {
const merged = deepMerge(out[key], value);
if (merged !== undefined) out[key] = merged;
}
return out as StateValue;
}
// Scalar / array conflict — remote wins as the deterministic tie-break.
return remote;
}

View File

@@ -0,0 +1,62 @@
# `ResourceAdapter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Maps the four device profiles (`EDGE` / `MOBILE` / `DESKTOP` / `CLOUD`) to per-profile sync knobs (history limit, Bloom-filter sizing, sync interval, compression level, battery-aware flag). Plugins consult the adapter once at boot and can switch profiles at runtime without restarting the manager.
## Features
- Frozen `PROFILE_DEFAULTS` table for the four profiles; `EDGE` is conservative, `CLOUD` is throughput-maximal.
- Per-profile overrides via the constructor / `switchProfile` second arg so deployments tune individual knobs without forking a profile.
- Throws `RESOURCE_PROFILE_UNKNOWN` when an unknown string sneaks past the union (defensive — TypeScript would normally catch this).
- Pure — no timers, no I/O. Caller wires the resolved config into the manager + Bloom comparator + sync engine.
## Usage: Basic Example
```ts
import { ResourceAdapter } from "@suaveplan/network-plugin-state";
const adapter = new ResourceAdapter({ profile: "MOBILE" });
adapter.config().syncIntervalMs; // 1_500
adapter.config().batteryAware; // true
```
## Usage: Advanced Example
```ts
import {
PROFILE_DEFAULTS,
ResourceAdapter,
} from "@suaveplan/network-plugin-state";
const adapter = new ResourceAdapter({
profile: "DESKTOP",
overrides: { syncIntervalMs: 100 }, // override one knob
});
// Switch to mobile when the host battery drops
adapter.switchProfile("MOBILE", { syncIntervalMs: 2_000 });
adapter.currentProfile(); // "MOBILE"
console.log(PROFILE_DEFAULTS.CLOUD.bloomExpectedItems); // 65_536
```
## API Reference
- `type ResourceProfile` — `"EDGE" | "MOBILE" | "DESKTOP" | "CLOUD"`.
- `interface ResourceProfileConfig` — `{ historyLimit, bloomExpectedItems, syncIntervalMs, compressionLevel, batteryAware }`.
- `const PROFILE_DEFAULTS` — frozen per-profile defaults.
- `class ResourceAdapter(config)`
- `switchProfile(profile, overrides?): void`.
- `currentProfile(): ResourceProfile`.
- `config(): ResourceProfileConfig`.
## Implementation Notes
- The defaults are intentionally coarse — enough resolution to differentiate the four profiles, but not so fine-grained that overrides become the common case.
- `switchProfile` rebuilds the active config from defaults + overrides. Stale overrides from the prior profile do not bleed into the new profile — this is intentional so callers can reason about the active config from the constructor + most-recent-switch arguments.
- `RESOURCE_PROFILE_UNKNOWN` is the boundary error for callers that pass a string typed as `string` rather than `ResourceProfile`. Internal callers using the union never trigger it.
- The adapter intentionally does NOT subscribe to host-OS events (battery, thermal). Wire those at a higher layer and call `switchProfile` from there.

View File

@@ -0,0 +1,93 @@
import { NETWORK_STATE_CODES, NetworkStateError } from "../error-codes.js";
export type ResourceProfile = "EDGE" | "MOBILE" | "DESKTOP" | "CLOUD";
export interface ResourceProfileConfig {
readonly historyLimit: number;
readonly bloomExpectedItems: number;
readonly syncIntervalMs: number;
readonly compressionLevel: "minimal" | "balanced" | "fast" | "max";
readonly batteryAware: boolean;
}
export const PROFILE_DEFAULTS: Readonly<
Record<ResourceProfile, ResourceProfileConfig>
> = Object.freeze({
EDGE: {
historyLimit: 64,
bloomExpectedItems: 256,
syncIntervalMs: 5_000,
compressionLevel: "max",
batteryAware: true,
},
MOBILE: {
historyLimit: 256,
bloomExpectedItems: 1_024,
syncIntervalMs: 1_500,
compressionLevel: "balanced",
batteryAware: true,
},
DESKTOP: {
historyLimit: 4_096,
bloomExpectedItems: 8_192,
syncIntervalMs: 250,
compressionLevel: "fast",
batteryAware: false,
},
CLOUD: {
historyLimit: 16_384,
bloomExpectedItems: 65_536,
syncIntervalMs: 100,
compressionLevel: "minimal",
batteryAware: false,
},
});
export interface ResourceAdapterConfig {
readonly profile: ResourceProfile;
readonly overrides?: Partial<ResourceProfileConfig>;
}
/**
* Selects per-profile sync knobs. Pure: no I/O, no timers.
*/
export class ResourceAdapter {
private current: ResourceProfileConfig;
private profile: ResourceProfile;
public constructor(config: ResourceAdapterConfig) {
this.profile = config.profile;
this.current = mergeProfile(config.profile, config.overrides);
}
public switchProfile(
profile: ResourceProfile,
overrides?: Partial<ResourceProfileConfig>
): void {
this.profile = profile;
this.current = mergeProfile(profile, overrides);
}
public currentProfile(): ResourceProfile {
return this.profile;
}
public config(): ResourceProfileConfig {
return this.current;
}
}
function mergeProfile(
profile: ResourceProfile,
overrides?: Partial<ResourceProfileConfig>
): ResourceProfileConfig {
const base = PROFILE_DEFAULTS[profile];
if (base === undefined) {
throw new NetworkStateError(
NETWORK_STATE_CODES.RESOURCE_PROFILE_UNKNOWN,
"resource.unknown-profile",
{ context: { profile } }
);
}
return { ...base, ...(overrides ?? {}) };
}

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_STATE_CODES, type NetworkStateError } from "../error-codes.js";
import {
PROFILE_DEFAULTS,
ResourceAdapter,
type ResourceProfile,
} from "./resource-adapter.js";
describe("ResourceAdapter: per-profile sync knobs", () => {
it("PROFILE_DEFAULTS covers every profile", () => {
const profiles: ResourceProfile[] = [
"EDGE",
"MOBILE",
"DESKTOP",
"CLOUD",
];
for (const p of profiles) {
expect(PROFILE_DEFAULTS[p]).toBeDefined();
}
});
it("constructs with defaults for the chosen profile", () => {
const a = new ResourceAdapter({ profile: "MOBILE" });
expect(a.config().syncIntervalMs).toBe(
PROFILE_DEFAULTS.MOBILE.syncIntervalMs
);
expect(a.currentProfile()).toBe("MOBILE");
});
it("overrides win over defaults", () => {
const a = new ResourceAdapter({
profile: "EDGE",
overrides: { syncIntervalMs: 100 },
});
expect(a.config().syncIntervalMs).toBe(100);
});
it("switchProfile rebuilds config from the new defaults", () => {
const a = new ResourceAdapter({
profile: "EDGE",
overrides: { syncIntervalMs: 100 },
});
a.switchProfile("CLOUD");
// CLOUD defaults — overrides did NOT carry over
expect(a.config().syncIntervalMs).toBe(
PROFILE_DEFAULTS.CLOUD.syncIntervalMs
);
});
it("switchProfile accepts new overrides", () => {
const a = new ResourceAdapter({ profile: "DESKTOP" });
a.switchProfile("MOBILE", { syncIntervalMs: 9_000 });
expect(a.config().syncIntervalMs).toBe(9_000);
});
it("unknown profile throws RESOURCE_PROFILE_UNKNOWN", () => {
try {
new ResourceAdapter({ profile: "BOGUS" as never });
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkStateError).code).toBe(
NETWORK_STATE_CODES.RESOURCE_PROFILE_UNKNOWN
);
}
});
});

View File

@@ -0,0 +1,66 @@
# `StateManager`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Path-based versioned state store for the mesh. Holds the local view of shared state, advances a strictly monotonic sequence on every mutation, records history for audit + reconciliation, and broadcasts snapshots to per-path subscribers. The sync engine wraps it; reconcilers / pruners read the history.
## Features
- `get` / `set` / `delete` over `.`-separated `StatePath`s.
- Subscriptions per path with unsubscribe-via-disposer return value.
- Bounded history (default 1024 entries) so long-running peers don't accumulate unbounded memory.
- `applyExternal` accepts a (path, value, timestamp) tuple from the sync engine without re-emitting through the local clock.
- Pure helpers `readPath` / `writePath` exported for tests + observability.
## Usage: Basic Example
```ts
import { StateManager } from "@suaveplan/network-plugin-state";
const manager = new StateManager();
manager.set("game.score", 0);
manager.subscribe("game.score", (s) => console.log("score=", s.value));
manager.set("game.score", 1);
```
## Usage: Advanced Example
```ts
import { StateManager } from "@suaveplan/network-plugin-state";
const manager = new StateManager({
clock: () => 1_700_000_000_000,
historyLimit: 4_096,
});
manager.set("players.alice", { x: 1, y: 2 });
manager.set("players.alice.x", 5);
manager.delete("players.alice.y");
const recent = manager.history();
const snap = manager.snapshot();
console.log(recent.length, snap.seq, snap.value);
```
## API Reference
- `class StateManager(config?)`
- `currentSeq(): StateSequence`.
- `get(path): StateValue | undefined` — throws `PATH_INVALID` on empty / non-string paths.
- `set(path, value): StateSnapshot` — auto-bumps `seq` and notifies subscribers.
- `delete(path): StateSnapshot`.
- `snapshot(): StateSnapshot` — full-tree snapshot at the current `seq`.
- `history(): ReadonlyArray<StateHistoryEntry>`.
- `subscribe(path, callback): () => void` — returns the unsubscribe disposer.
- `applyExternal(path, value, timestamp): StateSnapshot` — sync-engine path that respects the remote timestamp.
- `validatePath` / `readPath` / `writePath` — pure helpers.
## Implementation Notes
- `StateValue` is restricted to JSON-shaped values; the manager structurally clones along the write path so subscribers can hold references safely without observing mutations.
- The history ring uses `shift()` past the limit — adequate for the default 1024 entries; the pruner module owns the longer-tail eviction policy.
- `applyExternal` does NOT consult the manager's clock for the timestamp — the remote timestamp wins so reconcilers using `latest-wins` do not race themselves.
- `subscribe`'s returned disposer is idempotent: calling it twice just no-ops the second time. Subscribers can stash it without ref-counting.
- Notifications fire synchronously inside the mutation call. If subscribers throw, the manager has already committed the mutation — exception propagates out so observability sees it.

View File

@@ -0,0 +1,206 @@
import { NETWORK_STATE_CODES, NetworkStateError } from "../error-codes.js";
import type {
StateHistoryEntry,
StatePath,
StateSequence,
StateSnapshot,
StateValue,
} from "../types/state.types.js";
export type StateSubscriber = (snapshot: StateSnapshot) => void;
export interface StateManagerConfig {
readonly clock?: () => number;
/** Maximum history entries retained. Defaults to 1024. */
readonly historyLimit?: number;
}
/**
* Path-based versioned state store. Mutations advance a strictly
* monotonic sequence number; subscribers per path receive snapshots
* at the new sequence.
*/
export class StateManager {
private readonly clock: () => number;
private readonly historyLimit: number;
private state: Record<string, StateValue> = {};
private historyEntries: StateHistoryEntry[] = [];
private seq: StateSequence = 0;
private readonly subscribers = new Map<StatePath, Set<StateSubscriber>>();
public constructor(config: StateManagerConfig = {}) {
this.clock = config.clock ?? (() => Date.now());
this.historyLimit = Math.max(1, config.historyLimit ?? 1024);
}
public currentSeq(): StateSequence {
return this.seq;
}
public get(path: StatePath): StateValue | undefined {
validatePath(path);
return readPath(this.state, path);
}
public set(path: StatePath, value: StateValue): StateSnapshot {
validatePath(path);
const previous = readPath(this.state, path);
this.state = writePath(this.state, path, value);
return this.commit(path, previous, value);
}
public delete(path: StatePath): StateSnapshot {
validatePath(path);
const previous = readPath(this.state, path);
this.state = writePath(this.state, path, undefined);
return this.commit(path, previous, undefined);
}
public snapshot(): StateSnapshot {
return {
seq: this.seq,
value: this.state as StateValue,
timestamp: this.clock(),
};
}
public history(): ReadonlyArray<StateHistoryEntry> {
return [...this.historyEntries];
}
public subscribe(path: StatePath, callback: StateSubscriber): () => void {
validatePath(path);
let bucket = this.subscribers.get(path);
if (bucket === undefined) {
bucket = new Set();
this.subscribers.set(path, bucket);
}
bucket.add(callback);
const captured = bucket;
return () => {
captured.delete(callback);
if (captured.size === 0) this.subscribers.delete(path);
};
}
/** Apply an externally-supplied value (used by sync engine). */
public applyExternal(
path: StatePath,
value: StateValue | undefined,
timestamp: number
): StateSnapshot {
validatePath(path);
const previous = readPath(this.state, path);
this.state = writePath(this.state, path, value);
this.seq += 1;
const snapshot: StateSnapshot = {
seq: this.seq,
value: value as StateValue,
timestamp,
};
this.recordHistory({
seq: this.seq,
path,
previous,
next: value,
timestamp,
});
this.notify(path, snapshot);
return snapshot;
}
private commit(
path: StatePath,
previous: StateValue | undefined,
next: StateValue | undefined
): StateSnapshot {
const ts = this.clock();
this.seq += 1;
const snapshot: StateSnapshot = {
seq: this.seq,
value: next as StateValue,
timestamp: ts,
};
this.recordHistory({
seq: this.seq,
path,
previous,
next,
timestamp: ts,
});
this.notify(path, snapshot);
return snapshot;
}
private recordHistory(entry: StateHistoryEntry): void {
this.historyEntries.push(entry);
if (this.historyEntries.length > this.historyLimit) {
this.historyEntries.shift();
}
}
private notify(path: StatePath, snapshot: StateSnapshot): void {
const bucket = this.subscribers.get(path);
if (bucket === undefined) return;
for (const cb of bucket) cb(snapshot);
}
}
export function validatePath(path: StatePath): void {
if (typeof path !== "string" || path.length === 0) {
throw new NetworkStateError(
NETWORK_STATE_CODES.PATH_INVALID,
"state.path-empty",
{ context: { path } }
);
}
}
export function readPath(
root: Record<string, StateValue>,
path: StatePath
): StateValue | undefined {
const segments = path.split(".");
let current: StateValue | undefined = root as StateValue;
for (const segment of segments) {
if (
typeof current !== "object" ||
current === null ||
Array.isArray(current)
) {
return undefined;
}
current = (current as { [k: string]: StateValue })[segment];
if (current === undefined) return undefined;
}
return current;
}
export function writePath(
root: Record<string, StateValue>,
path: StatePath,
value: StateValue | undefined
): Record<string, StateValue> {
const segments = path.split(".");
const out: Record<string, StateValue> = { ...root };
let cursor: Record<string, StateValue> = out;
for (let i = 0; i < segments.length - 1; i += 1) {
const segment = segments[i] as string;
const existing = cursor[segment];
const nested: Record<string, StateValue> =
typeof existing === "object" &&
existing !== null &&
!Array.isArray(existing)
? { ...(existing as Record<string, StateValue>) }
: {};
cursor[segment] = nested;
cursor = nested;
}
const lastSegment = segments[segments.length - 1] as string;
if (value === undefined) {
delete cursor[lastSegment];
} else {
cursor[lastSegment] = value;
}
return out;
}

View File

@@ -0,0 +1,139 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_STATE_CODES, type NetworkStateError } from "../error-codes.js";
import {
readPath,
StateManager,
validatePath,
writePath,
} from "./state-manager.js";
describe("StateManager: get/set/delete + history + subscriptions", () => {
it("set / get round-trips primitive values", () => {
const m = new StateManager();
m.set("a.b", 1);
expect(m.get("a.b")).toBe(1);
});
it("set advances seq monotonically", () => {
const m = new StateManager();
const s1 = m.set("x", 1);
const s2 = m.set("y", 2);
expect(s2.seq).toBeGreaterThan(s1.seq);
});
it("delete removes a path", () => {
const m = new StateManager();
m.set("a.b.c", 5);
m.delete("a.b.c");
expect(m.get("a.b.c")).toBeUndefined();
});
it("subscribe receives snapshots and unsubscribes via disposer", () => {
const m = new StateManager();
const seen: number[] = [];
const dispose = m.subscribe("score", (s) =>
seen.push(s.value as number)
);
m.set("score", 1);
m.set("score", 2);
dispose();
m.set("score", 3);
expect(seen).toEqual([1, 2]);
});
it("snapshot reports the current tree + seq", () => {
const m = new StateManager();
m.set("a", 1);
m.set("b", 2);
const snap = m.snapshot();
expect(snap.value).toEqual({ a: 1, b: 2 });
expect(snap.seq).toBe(2);
});
it("history is bounded and FIFO-evicting", () => {
const m = new StateManager({ historyLimit: 3 });
for (let i = 0; i < 5; i += 1) m.set(`p${i}`, i);
expect(m.history().length).toBe(3);
});
it("path validation rejects empty / non-string paths", () => {
const m = new StateManager();
try {
m.set("", 1);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkStateError).code).toBe(
NETWORK_STATE_CODES.PATH_INVALID
);
}
});
it("validatePath helper exposes the same check", () => {
expect(() => validatePath("ok")).not.toThrow();
expect(() => validatePath("")).toThrow();
});
it("readPath / writePath operate on a plain record", () => {
const root = { a: { b: 1 } } as Record<string, unknown>;
expect(readPath(root as never, "a.b")).toBe(1);
const updated = writePath(root as never, "a.c", 5);
expect((updated as never)["a"]).toEqual({ b: 1, c: 5 });
const removed = writePath(root as never, "a.b", undefined);
expect((removed as never)["a"]).toEqual({});
});
it("readPath returns undefined for non-object intermediates", () => {
const root = { a: 1 } as Record<string, unknown>;
expect(readPath(root as never, "a.b")).toBeUndefined();
});
it("subscribe returning the disposer twice is idempotent", () => {
const m = new StateManager();
const dispose = m.subscribe("k", () => undefined);
dispose();
expect(() => dispose()).not.toThrow();
});
it("applyExternal stamps the supplied timestamp into history", () => {
const m = new StateManager({ clock: () => 0 });
m.applyExternal("ext", 1, 5_000);
const entry = m.history()[0];
expect(entry?.timestamp).toBe(5_000);
expect(entry?.next).toBe(1);
});
it("uses Date.now by default", () => {
const m = new StateManager();
const snap = m.set("x", 1);
expect(typeof snap.timestamp).toBe("number");
});
it("currentSeq starts at 0", () => {
expect(new StateManager().currentSeq()).toBe(0);
});
it("subscribe/unsubscribe cleans the bucket when empty", () => {
const m = new StateManager();
const dispose = m.subscribe("k", () => undefined);
dispose();
// Re-subscribe must work after the bucket was cleaned
const seen: number[] = [];
const dispose2 = m.subscribe("k", (s) => seen.push(s.value as number));
m.set("k", 7);
dispose2();
expect(seen).toEqual([7]);
});
it("dispose with sibling subscribers keeps the bucket alive", () => {
const m = new StateManager();
const a: number[] = [];
const b: number[] = [];
const dispA = m.subscribe("k", (s) => a.push(s.value as number));
m.subscribe("k", (s) => b.push(s.value as number));
m.set("k", 1);
dispA(); // bucket size goes 2 → 1, so subscribers map keeps the entry
m.set("k", 2);
expect(a).toEqual([1]);
expect(b).toEqual([1, 2]);
});
});

View File

@@ -0,0 +1,71 @@
# `PathStrategyRegistry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Maps `.`-separated path patterns to reconciliation strategies + optional `merge` callbacks. The sync engine consults the registry per delta entry to pick the right `ReconciliationStrategy` without hard-coding policy in the reconciler. First-match-wins ordering lets callers stack specific patterns above generic catch-alls.
## Features
- Closed pattern grammar: literals match exactly, `*` matches a single segment, `**` matches one or more segments greedily.
- Validation at registration: `custom` strategies missing a `merge` callback raise `STRATEGY_MISSING_MERGE` immediately.
- `resolve(path)` returns the first matching `(strategy, merge)` tuple; falls back to `latest-wins` when no pattern matches.
- `matches` exposed as a free function so tests / callers can validate pattern semantics independently.
## Usage: Basic Example
```ts
import {
PathStrategyRegistry,
} from "@suaveplan/network-plugin-state";
const registry = new PathStrategyRegistry();
registry.add({ pattern: "game.positions.*", strategy: "latest-wins" });
registry.add({ pattern: "game.inventory.*", strategy: "deep-merge" });
registry.resolve("game.positions.alice").strategy; // "latest-wins"
registry.resolve("game.unknown.x").strategy; // "latest-wins" (default)
```
## Usage: Advanced Example
```ts
import {
PathStrategyRegistry,
matches,
} from "@suaveplan/network-plugin-state";
const registry = new PathStrategyRegistry();
registry.add({
pattern: "audit.**",
strategy: "remote-wins",
});
registry.add({
pattern: "flags.*",
strategy: "custom",
merge: ({ localValue, remoteValue }) =>
Boolean(localValue) || Boolean(remoteValue),
});
matches("audit.**", "audit.users.alice.event"); // true (** is greedy)
matches("flags.*", "flags.tutorial"); // true
matches("flags.*", "flags.tutorial.completed"); // false (single segment)
```
## API Reference
- `class PathStrategyRegistry`
- `add(strategy: PathStrategy): void` — throws `STRATEGY_MISSING_MERGE` for `custom` without `merge`.
- `list(): ReadonlyArray<PathStrategy>`.
- `clear(): void`.
- `resolve(path): ResolvedStrategy`.
- `function matches(pattern: string, path: string): boolean`.
- `interface ResolvedStrategy` — `{ strategy, merge }`.
## Implementation Notes
- Registration order is the resolution order — there is no priority field. Putting more-specific patterns first is the explicit caller responsibility; the registry stays simple and predictable.
- `**` is greedy and matches one OR more segments (cannot match zero) so `audit.**` does not silently match `audit` itself.
- The default fallback is `latest-wins` because that is the safe / least-surprising strategy for game / collaborative state — most deployments override this with explicit registrations.
- `matches` is implemented recursively for clarity; the typical pattern length is small (≤5 segments) so the recursion depth is bounded.

View File

@@ -0,0 +1,89 @@
import { NETWORK_STATE_CODES, NetworkStateError } from "../error-codes.js";
import type {
PathStrategy,
ReconciliationStrategy,
} from "../types/reconciliation.types.js";
export interface ResolvedStrategy {
readonly strategy: ReconciliationStrategy;
readonly merge: PathStrategy["merge"];
}
const DEFAULT_STRATEGY: ResolvedStrategy = Object.freeze({
strategy: "latest-wins",
merge: undefined,
});
/**
* Maps glob-like patterns to reconciliation strategies. Patterns are
* matched in registration order — first match wins so callers can
* stack specific patterns above generic catch-alls.
*/
export class PathStrategyRegistry {
private readonly strategies: PathStrategy[] = [];
public add(strategy: PathStrategy): void {
if (strategy.strategy === "custom" && strategy.merge === undefined) {
throw new NetworkStateError(
NETWORK_STATE_CODES.STRATEGY_MISSING_MERGE,
"strategy.custom-missing-merge",
{ context: { pattern: strategy.pattern } }
);
}
this.strategies.push(strategy);
}
public list(): ReadonlyArray<PathStrategy> {
return [...this.strategies];
}
public clear(): void {
this.strategies.length = 0;
}
public resolve(path: string): ResolvedStrategy {
for (const entry of this.strategies) {
if (matches(entry.pattern, path)) {
return {
strategy: entry.strategy,
merge: entry.merge,
};
}
}
return DEFAULT_STRATEGY;
}
}
/**
* Pattern grammar:
* - Literal segments match exactly.
* - `*` matches a single segment.
* - `**` matches one or more segments (greedy).
*/
export function matches(pattern: string, path: string): boolean {
const patternSegments = pattern.split(".");
const pathSegments = path.split(".");
return matchSegments(patternSegments, 0, pathSegments, 0);
}
function matchSegments(
pattern: ReadonlyArray<string>,
pi: number,
path: ReadonlyArray<string>,
si: number
): boolean {
if (pi === pattern.length) return si === path.length;
const segment = pattern[pi];
if (segment === "**") {
// `**` consumes at least one segment so `a.**` does not match `a`.
for (let nextSi = si + 1; nextSi <= path.length; nextSi += 1) {
if (matchSegments(pattern, pi + 1, path, nextSi)) return true;
}
return false;
}
if (si === path.length) return false;
if (segment === "*" || segment === path[si]) {
return matchSegments(pattern, pi + 1, path, si + 1);
}
return false;
}

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_STATE_CODES, type NetworkStateError } from "../error-codes.js";
import { matches, PathStrategyRegistry } from "./path-strategy-registry.js";
describe("PathStrategyRegistry: pattern matching + first-match-wins", () => {
it("first-match-wins by registration order", () => {
const registry = new PathStrategyRegistry();
registry.add({ pattern: "a.b", strategy: "remote-wins" });
registry.add({ pattern: "*.b", strategy: "local-wins" });
expect(registry.resolve("a.b").strategy).toBe("remote-wins");
expect(registry.resolve("c.b").strategy).toBe("local-wins");
});
it("default strategy for unmatched paths is latest-wins", () => {
const registry = new PathStrategyRegistry();
expect(registry.resolve("anything").strategy).toBe("latest-wins");
});
it("rejects custom strategy missing merge", () => {
const registry = new PathStrategyRegistry();
try {
registry.add({ pattern: "x", strategy: "custom" });
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkStateError).code).toBe(
NETWORK_STATE_CODES.STRATEGY_MISSING_MERGE
);
}
});
it("custom strategy with merge is accepted and resolves merge fn", () => {
const registry = new PathStrategyRegistry();
const merge = () => undefined;
registry.add({ pattern: "x", strategy: "custom", merge });
const resolved = registry.resolve("x");
expect(resolved.strategy).toBe("custom");
expect(resolved.merge).toBe(merge);
});
it("clear empties the registry", () => {
const registry = new PathStrategyRegistry();
registry.add({ pattern: "x", strategy: "local-wins" });
registry.clear();
expect(registry.list().length).toBe(0);
});
it("matches: literal", () => {
expect(matches("a.b", "a.b")).toBe(true);
expect(matches("a.b", "a.c")).toBe(false);
});
it("matches: single-segment wildcard", () => {
expect(matches("a.*", "a.b")).toBe(true);
expect(matches("a.*", "a.b.c")).toBe(false);
});
it("matches: ** matches one or more segments", () => {
expect(matches("a.**", "a.b")).toBe(true);
expect(matches("a.**", "a.b.c.d")).toBe(true);
expect(matches("a.**", "a")).toBe(false);
});
it("matches: pattern longer than path returns false", () => {
expect(matches("a.b.c", "a.b")).toBe(false);
});
it("matches: pattern shorter than path returns false (no `**`)", () => {
expect(matches("a.b", "a.b.c")).toBe(false);
});
});

View File

@@ -0,0 +1,82 @@
# `StateSyncEngine`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wires the manager + delta compressor + reconciler + path strategy registry into a single sync facade. Builds outgoing `DeltaMessage` / `FullSyncMessage`s from the local snapshot, and applies incoming messages by routing each entry through the per-path strategy. Subscribers see resolved values as ordinary `applyExternal` notifications.
## Features
- `buildDelta` advances an internal "last-base" pointer so subsequent calls only emit incremental changes.
- `buildFullSync` produces the bootstrap snapshot for a newly-joined peer.
- `applyDelta` rejects messages whose `fromSeq` is ahead of the local sequence (`DELTA_OUT_OF_ORDER`) — receivers fall back to `applyFullSync` on this error.
- `applyFullSync` runs every changed key through the reconciler so peer-specific path policies still trigger during catch-up.
- Optional injection of `compressor` / `reconciler` / `registry` so tests and customised pipelines stay flexible.
## Usage: Basic Example
```ts
import {
StateManager,
StateSyncEngine,
} from "@suaveplan/network-plugin-state";
const manager = new StateManager();
const sync = new StateSyncEngine({ manager });
manager.set("game.score", 5);
const delta = sync.buildDelta();
// delta.entries[0].path === "game.score"
```
## Usage: Advanced Example
```ts
import {
PathStrategyRegistry,
StateManager,
StateReconciler,
StateSyncEngine,
} from "@suaveplan/network-plugin-state";
const manager = new StateManager();
const registry = new PathStrategyRegistry();
registry.add({ pattern: "game.inventory.*", strategy: "deep-merge" });
const sync = new StateSyncEngine({
manager,
reconciler: new StateReconciler(),
registry,
});
manager.set("game.inventory.coins", 10);
sync.applyDelta({
kind: "delta",
fromSeq: manager.currentSeq(),
toSeq: manager.currentSeq() + 1,
entries: [
{
path: "game.inventory.gems",
value: 1,
timestamp: Date.now(),
},
],
});
manager.get("game.inventory.coins"); // 10 (preserved by deep-merge)
manager.get("game.inventory.gems"); // 1 (added)
```
## API Reference
- `class StateSyncEngine(config: StateSyncEngineConfig)`
- `buildDelta(): DeltaMessage`.
- `buildFullSync(): FullSyncMessage`.
- `applyDelta(message): void` — throws `DELTA_OUT_OF_ORDER` when `message.fromSeq > local.seq`.
- `applyFullSync(message): void`.
## Implementation Notes
- `lastBaseSeq` is private state — clones of the engine cannot share a base pointer. Production deployments use one engine per local manager.
- `buildDelta` always advances the base pointer even when zero entries change, so callers can poll the engine without re-shipping the same entries.
- `applyEntry` reads the local value via `manager.get(path)` rather than walking the snapshot so subscribers observe the post-resolution value via `applyExternal`'s notification path — keeping the reactive surface uniform.
- `applyFullSync` does NOT bypass the reconciler — even bootstrap traffic respects path strategies so a new peer joining a deep-merge zone observes the merge semantics on the first message.

View File

@@ -0,0 +1,139 @@
import { DeltaCompressor } from "../delta/delta-compressor.js";
import { NETWORK_STATE_CODES, NetworkStateError } from "../error-codes.js";
import { StateReconciler } from "../reconciler/state-reconciler.js";
import type { StateManager } from "../state/state-manager.js";
import { PathStrategyRegistry } from "../strategy/path-strategy-registry.js";
import type { ConflictInput } from "../types/reconciliation.types.js";
import type { StateValue } from "../types/state.types.js";
import type { DeltaMessage, FullSyncMessage } from "../types/sync.types.js";
export interface StateSyncEngineConfig {
readonly manager: StateManager;
readonly compressor?: DeltaCompressor;
readonly reconciler?: StateReconciler;
readonly registry?: PathStrategyRegistry;
}
/**
* Glue between the manager + delta compressor + reconciler + registry.
* Owns the wire-protocol shapes (`DeltaMessage` / `FullSyncMessage`)
* and the apply pipeline.
*/
export class StateSyncEngine {
private readonly manager: StateManager;
private readonly compressor: DeltaCompressor;
private readonly reconciler: StateReconciler;
private readonly registry: PathStrategyRegistry;
private lastBaseSnapshot: StateValue;
private lastBaseSeq: number;
public constructor(config: StateSyncEngineConfig) {
this.manager = config.manager;
this.compressor = config.compressor ?? new DeltaCompressor();
this.reconciler = config.reconciler ?? new StateReconciler();
this.registry = config.registry ?? new PathStrategyRegistry();
const snapshot = this.manager.snapshot();
this.lastBaseSnapshot = snapshot.value;
this.lastBaseSeq = snapshot.seq;
}
/**
* Build a delta message from `lastBaseSeq` to the current local seq.
* After build, `lastBaseSeq` advances so the next call ships only the
* incremental changes.
*/
public buildDelta(): DeltaMessage {
const snapshot = this.manager.snapshot();
const entries = this.compressor.compute(
this.lastBaseSnapshot,
snapshot.value,
snapshot.timestamp
);
const message: DeltaMessage = {
kind: "delta",
fromSeq: this.lastBaseSeq,
toSeq: snapshot.seq,
entries,
};
this.lastBaseSnapshot = snapshot.value;
this.lastBaseSeq = snapshot.seq;
return message;
}
public buildFullSync(): FullSyncMessage {
const snapshot = this.manager.snapshot();
return {
kind: "full",
seq: snapshot.seq,
snapshot: snapshot.value,
timestamp: snapshot.timestamp,
};
}
public applyDelta(message: DeltaMessage): void {
const local = this.manager.snapshot();
if (message.fromSeq > local.seq) {
throw new NetworkStateError(
NETWORK_STATE_CODES.DELTA_OUT_OF_ORDER,
"sync.delta-future",
{
context: {
fromSeq: message.fromSeq,
localSeq: local.seq,
},
}
);
}
for (const entry of message.entries) {
this.applyEntry(entry.path, entry.value, entry.timestamp);
}
}
public applyFullSync(message: FullSyncMessage): void {
// Compute a delta from current to the remote snapshot and apply
// every entry through the reconciler so policies still trigger.
const local = this.manager.snapshot();
const entries = this.compressor.compute(
local.value,
message.snapshot,
message.timestamp
);
for (const entry of entries) {
this.applyEntry(entry.path, entry.value, entry.timestamp);
}
}
private applyEntry(
path: string,
remoteValue: StateValue | undefined,
remoteTimestamp: number
): void {
const localSnap = this.manager.snapshot();
const localValue =
path === "$root" ? localSnap.value : this.manager.get(path);
const conflict: ConflictInput = {
path,
localValue,
localTimestamp: localSnap.timestamp,
remoteValue,
remoteTimestamp,
};
const resolved = this.registry.resolve(path);
const out = this.reconciler.resolve(
resolved.strategy,
conflict,
resolved.merge
);
if (path === "$root") {
// Re-apply via per-key sets so subscribers fire normally.
const next = (out.resolvedValue ?? {}) as {
[k: string]: StateValue;
};
for (const key of Object.keys(next)) {
this.manager.applyExternal(key, next[key], remoteTimestamp);
}
return;
}
this.manager.applyExternal(path, out.resolvedValue, remoteTimestamp);
}
}

View File

@@ -0,0 +1,176 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_STATE_CODES, type NetworkStateError } from "../error-codes.js";
import { StateManager } from "../state/state-manager.js";
import { PathStrategyRegistry } from "../strategy/path-strategy-registry.js";
import { StateSyncEngine } from "./state-sync-engine.js";
const setup = () => {
const manager = new StateManager();
const registry = new PathStrategyRegistry();
const sync = new StateSyncEngine({ manager, registry });
return { manager, registry, sync };
};
describe("StateSyncEngine: build + apply messages", () => {
it("buildDelta emits zero entries on a fresh manager", () => {
const { sync } = setup();
const msg = sync.buildDelta();
expect(msg.entries).toEqual([]);
});
it("buildDelta advances fromSeq across calls", () => {
const { manager, sync } = setup();
manager.set("a", 1);
const m1 = sync.buildDelta();
manager.set("b", 2);
const m2 = sync.buildDelta();
expect(m2.fromSeq).toBe(m1.toSeq);
});
it("buildFullSync returns a snapshot message", () => {
const { manager, sync } = setup();
manager.set("a", 1);
const msg = sync.buildFullSync();
expect(msg.kind).toBe("full");
expect(msg.snapshot).toEqual({ a: 1 });
});
it("applyDelta runs entries through the reconciler", () => {
const { manager, sync, registry } = setup();
registry.add({ pattern: "x", strategy: "remote-wins" });
manager.set("x", "L");
sync.applyDelta({
kind: "delta",
fromSeq: manager.currentSeq(),
toSeq: manager.currentSeq() + 1,
entries: [{ path: "x", value: "R", timestamp: 100 }],
});
expect(manager.get("x")).toBe("R");
});
it("applyDelta out-of-order throws DELTA_OUT_OF_ORDER", () => {
const { manager, sync } = setup();
manager.set("x", 1);
try {
sync.applyDelta({
kind: "delta",
fromSeq: manager.currentSeq() + 100,
toSeq: manager.currentSeq() + 200,
entries: [],
});
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkStateError).code).toBe(
NETWORK_STATE_CODES.DELTA_OUT_OF_ORDER
);
}
});
it("applyFullSync routes through the reconciler too", () => {
const { manager, sync, registry } = setup();
registry.add({ pattern: "*", strategy: "remote-wins" });
manager.set("a", 1);
sync.applyFullSync({
kind: "full",
seq: manager.currentSeq() + 1,
snapshot: { a: 5 },
timestamp: 100,
});
expect(manager.get("a")).toBe(5);
});
it("applyDelta preserves deep-merge for matching paths", () => {
const { manager, sync, registry } = setup();
registry.add({
pattern: "inv.*",
strategy: "deep-merge",
});
manager.set("inv.coins", 10);
sync.applyDelta({
kind: "delta",
fromSeq: manager.currentSeq(),
toSeq: manager.currentSeq() + 1,
entries: [
{
path: "inv.gems",
value: 1,
timestamp: 100,
},
],
});
expect(manager.get("inv.coins")).toBe(10);
expect(manager.get("inv.gems")).toBe(1);
});
it("buildDelta is idempotent when nothing changes between calls", () => {
const { manager, sync } = setup();
manager.set("x", 1);
sync.buildDelta();
const m2 = sync.buildDelta();
expect(m2.entries).toEqual([]);
});
it("default-config (no registry) uses default reconciler", () => {
const manager = new StateManager();
const sync = new StateSyncEngine({ manager });
manager.set("x", "old");
sync.applyDelta({
kind: "delta",
fromSeq: manager.currentSeq(),
toSeq: manager.currentSeq() + 1,
entries: [
{ path: "x", value: "new", timestamp: Date.now() + 1_000 },
],
});
// latest-wins default → remote wins on a fresher timestamp
expect(manager.get("x")).toBe("new");
});
it("applyFullSync handles $root replacement without crashing", () => {
const { manager, sync } = setup();
manager.set("a", 1);
sync.applyFullSync({
kind: "full",
seq: 999,
snapshot: { b: 2 },
timestamp: Date.now() + 1_000,
});
expect(manager.get("b")).toBe(2);
});
it("applyDelta with $root entry rewrites the whole tree per-key", () => {
const { manager, sync } = setup();
sync.applyDelta({
kind: "delta",
fromSeq: manager.currentSeq(),
toSeq: manager.currentSeq() + 1,
entries: [
{
path: "$root",
value: { x: 1, y: 2 },
timestamp: Date.now() + 1_000,
},
],
});
expect(manager.get("x")).toBe(1);
expect(manager.get("y")).toBe(2);
});
it("applyDelta with $root undefined value resolves to {} per-key", () => {
const { manager, sync } = setup();
sync.applyDelta({
kind: "delta",
fromSeq: manager.currentSeq(),
toSeq: manager.currentSeq() + 1,
entries: [
{
path: "$root",
value: undefined,
timestamp: Date.now() + 1_000,
},
],
});
// Empty tree — no keys to write
expect(manager.snapshot().value).toEqual({});
});
});

View File

@@ -0,0 +1,63 @@
# Reconciliation types — `@suaveplan/network-plugin-state`
## Purpose
Shared types for the conflict-resolution layer. `ReconciliationStrategy` is the closed five-strategy union the reconciler and registry both consume; `ConflictInput` and `ConflictResolution` describe the canonical I/O shape; `PathStrategy` lets the registry map glob patterns to per-strategy + optional custom merge.
## Features
- Closed union `ReconciliationStrategy` so exhaustive switches stay honest.
- `ConflictInput` carries both timestamps so `latest-wins` decides without leaking timing logic into the reconciler signature.
- `ConflictResolution.winner` reports whether the result came from the local snapshot, the remote snapshot, or a merge — useful for observability + audit.
- `CustomMergeFn` typed so the registry can validate the merge presence at registration time.
## Usage: Basic Example
```ts
import type {
ConflictInput,
ConflictResolution,
ReconciliationStrategy,
} from "@suaveplan/network-plugin-state";
const strategy: ReconciliationStrategy = "latest-wins";
declare const input: ConflictInput;
declare const out: ConflictResolution;
console.log(out.winner, out.strategy);
```
## Usage: Advanced Example
```ts
import type {
CustomMergeFn,
PathStrategy,
} from "@suaveplan/network-plugin-state";
const mergeArrays: CustomMergeFn = ({ localValue, remoteValue }) => {
const a = Array.isArray(localValue) ? localValue : [];
const b = Array.isArray(remoteValue) ? remoteValue : [];
return Array.from(new Set([...a, ...b]));
};
const strategies: PathStrategy[] = [
{ pattern: "game.positions.*", strategy: "latest-wins" },
{ pattern: "game.inventory.*", strategy: "deep-merge" },
{ pattern: "game.flags.*", strategy: "custom", merge: mergeArrays },
];
```
## API Reference
- `type ReconciliationStrategy` — `"latest-wins" | "remote-wins" | "local-wins" | "deep-merge" | "custom"`.
- `interface ConflictInput` — `{ path, localValue, localTimestamp, remoteValue, remoteTimestamp }`.
- `interface ConflictResolution` — `{ path, resolvedValue, winner, strategy }`.
- `type CustomMergeFn` — `(input: ConflictInput) => StateValue | undefined`.
- `interface PathStrategy` — `{ pattern, strategy, merge? }`.
## Implementation Notes
- `localValue` and `remoteValue` are `StateValue | undefined` so the same shape describes deletes (one side `undefined`) without a separate flag.
- The registry stores patterns in registration order and matches first-wins; consumers can stack increasingly-specific patterns at the top so generic catch-alls do not eclipse per-path overrides.
- `winner: "merged"` is the only outcome where `resolvedValue` is computed rather than copied; subscribers can short-circuit on `local`/`remote` by reading the matching original snapshot.
- `CustomMergeFn` MAY return `undefined` to signal "delete the path" — the manager treats this as a regular delete in the next sync cycle.

View File

@@ -0,0 +1,33 @@
import type { StatePath, StateValue } from "./state.types.js";
export type ReconciliationStrategy =
| "latest-wins"
| "remote-wins"
| "local-wins"
| "deep-merge"
| "custom";
export interface ConflictInput {
readonly path: StatePath;
readonly localValue: StateValue | undefined;
readonly localTimestamp: number;
readonly remoteValue: StateValue | undefined;
readonly remoteTimestamp: number;
}
export interface ConflictResolution {
readonly path: StatePath;
readonly resolvedValue: StateValue | undefined;
readonly winner: "local" | "remote" | "merged";
readonly strategy: ReconciliationStrategy;
}
export type CustomMergeFn = (input: ConflictInput) => StateValue | undefined;
export interface PathStrategy {
/** Glob pattern — `*` matches a single segment, `**` matches recursive. */
readonly pattern: string;
readonly strategy: ReconciliationStrategy;
/** Required when `strategy === "custom"`. */
readonly merge?: CustomMergeFn;
}

View File

@@ -0,0 +1,61 @@
# State types — `@suaveplan/network-plugin-state`
## Purpose
Shared structural types for the state layer. Every other module — manager, delta compressor, reconciler, sync engine, pruner — speaks the same `StateValue` / `StateSnapshot` / `StateHistoryEntry` shapes so callers compose pipelines without inventing per-module conversions.
## Features
- Closed `StateValue` recursive union restricted to JSON-serialisable shapes; reconcilers can compare values structurally without worrying about cycles.
- `StateSequence` typed as `number` rather than a branded type so consumers can compare with `>`/`<` directly while still expressing intent.
- `StateHistoryEntry` captures the canonical "before / after / when" tuple the reconciler and pruner consume.
## Usage: Basic Example
```ts
import type {
StateHistoryEntry,
StatePath,
StateValue,
} from "@suaveplan/network-plugin-state";
const path: StatePath = "game.players.alice.position";
const value: StateValue = { x: 1, y: 2, z: 3 };
declare const entry: StateHistoryEntry;
console.log(entry.seq, entry.timestamp, entry.next);
```
## Usage: Advanced Example
```ts
import type {
StateSnapshot,
StateValue,
} from "@suaveplan/network-plugin-state";
const snapshot: StateSnapshot = {
seq: 42,
value: {
players: { alice: { position: [1, 2, 3] }, bob: { position: [4, 5, 6] } },
} satisfies StateValue,
timestamp: 1_700_000_000_000,
};
declare function broadcast(s: StateSnapshot): void;
broadcast(snapshot);
```
## API Reference
- `type StatePath` — `string` (`.`-separated segments; empty string is root).
- `type StateSequence` — `number`.
- `type StateValue` — `null | boolean | number | string | ReadonlyArray<StateValue> | { [k]: StateValue }`.
- `interface StateHistoryEntry` — `{ seq, path, previous, next, timestamp }`.
- `interface StateSnapshot` — `{ seq, value, timestamp }`.
## Implementation Notes
- `StateValue` is restricted to JSON-shaped values so the delta compressor can serialise without bespoke handling of `Date` / `BigInt` / `Map` types. Callers that need richer shapes should wrap them in `{ kind, payload }` envelopes.
- `previous` and `next` on `StateHistoryEntry` are `StateValue | undefined` so the same record describes set, update, and delete operations without an extra discriminator.
- `StateSnapshot.seq` is the sequence number at the time the snapshot was taken; subscribers use it to skip older snapshots on out-of-order delivery.
- Types live in dedicated files so cross-module type-only imports stay zero-cost at runtime.

View File

@@ -0,0 +1,42 @@
/**
* Path notation: `"."`-separated segments. Empty string is the root.
*/
export type StatePath = string;
/**
* Sequence number per local state mutation. Strictly monotonic per
* `StateManager` instance.
*/
export type StateSequence = number;
/**
* State value — JSON-serialisable. The reconciler compares values via
* structural equality.
*/
export type StateValue =
| null
| boolean
| number
| string
| ReadonlyArray<StateValue>
| { readonly [key: string]: StateValue };
/**
* Per-mutation history entry the manager records before applying.
*/
export interface StateHistoryEntry {
readonly seq: StateSequence;
readonly path: StatePath;
readonly previous: StateValue | undefined;
readonly next: StateValue | undefined;
readonly timestamp: number;
}
/**
* Snapshot returned to subscribers and the sync engine.
*/
export interface StateSnapshot {
readonly seq: StateSequence;
readonly value: StateValue;
readonly timestamp: number;
}

View File

@@ -0,0 +1,60 @@
# Sync types — `@suaveplan/network-plugin-state`
## Purpose
Wire shapes the sync engine exchanges between peers. `DeltaMessage` covers the steady-state case (apply only the changed paths between two sequence numbers); `FullSyncMessage` covers the catch-up case (a new peer needs the whole snapshot). The discriminator `kind` lets receivers branch with exhaustive type checks.
## Features
- Discriminated `SyncMessage` union so receivers MUST handle both arms.
- `DeltaMessage` carries `fromSeq`/`toSeq` so consumers detect gaps and request full sync if needed.
- `FullSyncMessage.snapshot` holds the entire `StateValue` tree so receivers can rebuild without back-references.
- `DeltaEntry` accepts `value: StateValue | undefined` so deletes piggyback on the same wire shape as updates.
## Usage: Basic Example
```ts
import type {
DeltaMessage,
FullSyncMessage,
SyncMessage,
} from "@suaveplan/network-plugin-state";
declare const msg: SyncMessage;
if (msg.kind === "delta") {
msg.entries.forEach((e) => console.log(e.path, e.value));
} else {
console.log("full sync at seq", msg.seq);
}
```
## Usage: Advanced Example
```ts
import type { DeltaMessage } from "@suaveplan/network-plugin-state";
const delta: DeltaMessage = {
kind: "delta",
fromSeq: 42,
toSeq: 47,
entries: [
{ path: "game.players.alice.x", value: 5, timestamp: 1_700_000_001_000 },
{ path: "game.flags.tutorial", value: undefined, timestamp: 1_700_000_002_000 },
],
};
```
## API Reference
- `type SyncMode` — `"delta" | "full"`.
- `interface DeltaEntry` — `{ path, value, timestamp }`.
- `interface DeltaMessage` — `{ kind: "delta", fromSeq, toSeq, entries }`.
- `interface FullSyncMessage` — `{ kind: "full", seq, snapshot, timestamp }`.
- `type SyncMessage` — `DeltaMessage | FullSyncMessage`.
## Implementation Notes
- The discriminator (`kind`) lives on the message rather than relying on field-presence checks because TypeScript's narrowing is more reliable on closed strings than on optional-property tests.
- `fromSeq < toSeq` is enforced by the sync engine; receivers can assert it for defensive observability.
- `FullSyncMessage` does not carry a `from` because the receiver is bootstrapping — there is no prior sequence to anchor against.
- `DeltaEntry.value` is intentionally optional-ish (`StateValue | undefined`) rather than a separate `tombstone: true` flag: this keeps the wire shape narrow and the apply path branchless.

View File

@@ -0,0 +1,28 @@
import type { StatePath, StateSequence, StateValue } from "./state.types.js";
/**
* Discriminator for sync messages exchanged between peers.
*/
export type SyncMode = "delta" | "full";
export interface DeltaEntry {
readonly path: StatePath;
readonly value: StateValue | undefined;
readonly timestamp: number;
}
export interface DeltaMessage {
readonly kind: "delta";
readonly fromSeq: StateSequence;
readonly toSeq: StateSequence;
readonly entries: ReadonlyArray<DeltaEntry>;
}
export interface FullSyncMessage {
readonly kind: "full";
readonly seq: StateSequence;
readonly snapshot: StateValue;
readonly timestamp: number;
}
export type SyncMessage = DeltaMessage | FullSyncMessage;

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-plugin-state dep-graph", () => {
it("loads index.js and exports every public class", async () => {
const mod = await import("../src/index.js");
expect(mod.StateManager).toBeDefined();
expect(mod.DeltaCompressor).toBeDefined();
expect(mod.StateReconciler).toBeDefined();
expect(mod.PathStrategyRegistry).toBeDefined();
expect(mod.BloomFilter).toBeDefined();
expect(mod.BloomFilterComparator).toBeDefined();
expect(mod.StateSyncEngine).toBeDefined();
expect(mod.ResourceAdapter).toBeDefined();
expect(mod.StateHistoryPruner).toBeDefined();
expect(mod.StatePlugin).toBeDefined();
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

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

View File

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

View File

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