first commit
This commit is contained in:
32
docs/networking/network/network-plugin-state/README.md
Normal file
32
docs/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,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,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,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,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,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,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,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,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,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,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,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,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,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.
|
||||
Reference in New Issue
Block a user