Initial Commit
This commit is contained in:
32
packages/networking/network/network-plugin-state/README.md
Normal file
32
packages/networking/network/network-plugin-state/README.md
Normal 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…
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 ?? {}) };
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"references": [{ "path": "./tsconfig.lib.json" }]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/network-plugin-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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: [
|
||||
"src/__benchmarks__/**",
|
||||
"benchmarks/**",
|
||||
"e2e/**",
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user