Initial Commit

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

View File

@@ -0,0 +1,61 @@
# @suaveplan/bitpacker-delta - Bitpacker delta plugin
## Summary
Delta encoding utilities for efficient repeated state transmission. Detects changes between successive values and encodes differences rather than full values.
<!-- begin:auto-docs -->
## Documentation tree
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
<!-- end:auto-docs --> - [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_ - [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
## Install
```bash
bun add @suaveplan/bitpacker-delta
```
## Usage
```typescript
import { DeltaManager, calculateStringDiff, applyStringDiff, numberXor } from "@suaveplan/bitpacker-delta";
// Change detection
const dm = new DeltaManager();
dm.detectChanges({ x: 1, y: 2 }); // returns { x: 1, y: 2 }
dm.detectChanges({ x: 1, y: 2 }); // returns undefined (no change)
dm.detectChanges({ x: 1, y: 3 }); // returns { x: 1, y: 3 }
// String diff
const diff = calculateStringDiff("hello world", "hello there");
// { prefixLen: 6, suffixLen: 0, middleLen: 5, middle: "there" }
const reconstructed = applyStringDiff("hello world", diff);
// "hello there"
// Numeric XOR delta
const delta = numberXor(3.14159, 2.71828);
```
## Modules
- `src/index.ts` — `DeltaManager`, change detection API
- `src/xor-encoder.ts` — XOR-based numeric delta encoding
- `src/string-diff.ts` — Common prefix/suffix string diff encoding

View File

@@ -0,0 +1,48 @@
{
"name": "@suaveplan/bitpacker-delta",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Bitpacker delta plugin",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"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 --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/"
},
"dependencies": {
"@suaveplan/bitpacker": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
}
}

View File

@@ -0,0 +1,123 @@
# DeltaManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DeltaManager` is the field-level delta detector for bit-packed objects. Given a current state and a `BitPackerSchema` describing it, it returns the ordered list of fields whose value has changed since the previous snapshot stored under the same state key. Encoders use the returned `FieldChange[]` to build a fixed-width bitmask and send only the changed fields, dramatically reducing bandwidth for high-frequency state-sync workloads (game replication, multi-peer document sync, telemetry deltas).
This is the port of suaveplan's upstream `core/delta/delta-manager.ts`. hardening replaces upstream's `Date.now()` with injectable `Clock` (CLAUDE.md determinism rule), upstream's `JSON.stringify`-based equality with a structural walker that respects `BitPackerSchema` field order (avoiding subtle false-positive diffs from key-order changes in nested objects), upstream's plain `Error` throws with `BitpackerError` codes, and validates `DeltaConfig` via Zod with strict-mode key rejection.
## Features
- Field-indexed `FieldChange[]` output matching `Object.keys(schema)` order for stable bit offsets
- Per-state-key snapshot store with LRU eviction (delegates to `DeltaStateManager`)
- Configurable `maxFieldCount` cap with `BITPACKER.DELTA_FIELD_LIMIT_EXCEEDED` enforcement
- Structural equality default (matches schema semantics) with opt-in `equalityMode: "json"` for upstream byte-compat
- Zod-validated `DeltaConfig`; unknown keys rejected with `BITPACKER.DELTA_INVALID_CONFIG`
- Threshold-driven `shouldUseDelta(originalBits, deltaBits)` for bandwidth-savings decisions
- Zero `Math.random` / `Date.now` in the implementation — fully deterministic given identical inputs
## Usage: Basic Example
```typescript
import { DeltaManager } from "@suaveplan/bitpacker-delta";
const schema = { x: "u32", y: "u32" } as const;
const dm = new DeltaManager({ mode: "field-level" });
// First call: no previous state, so no changes.
dm.detectChanges({ x: 0, y: 0 }, schema); // → []
dm.storePreviousState({ x: 0, y: 0 });
// Second call: x changed.
dm.detectChanges({ x: 5, y: 0 }, schema);
// → [{ index: 0, key: "x", previousValue: 0, currentValue: 5 }]
```
## Usage: Advanced Example
```typescript
import { DeltaManager } from "@suaveplan/bitpacker-delta";
const schema = {
pos: { x: "f32", y: "f32" },
vel: { x: "f32", y: "f32" },
health: "u8",
} as const;
// Per-peer state streams: one DeltaManager, many state keys.
const dm = new DeltaManager(
{ mode: "field-level", threshold: 25, maxFieldCount: 16 },
{ maxStates: 256 } // 256 simultaneous peers
);
for (const peer of peers) {
// Each peer is its own state stream.
const previous = dm.getPreviousState(peer.id);
const changes = dm.detectChanges(peer.state, schema, peer.id);
// Only send a delta if it saves at least 25% of bits.
const fullSize = bitWidthOf(schema);
const deltaSize = changes.length * 32 + Math.ceil(3 / 8) * 8;
if (dm.shouldUseDelta(fullSize, deltaSize)) {
sendDelta(peer.id, changes);
} else {
sendFull(peer.id, peer.state);
}
dm.storePreviousState(peer.state, peer.id);
}
```
## API Reference
### `new DeltaManager(config?: DeltaConfig, options?: DeltaManagerOptions)`
`config.mode` ∈ `"none" | "xor" | "string-diff" | "field-level"`. Default `"none"`.
`config.threshold` — bandwidth-savings %, 0–100. Default `0`.
`config.maxFieldCount` — max fields the bitmask can address. Default `64`.
`config.stateKey` — default key for `detectChanges` calls without one. Default `"default"`.
`config.equalityMode` — `"structural"` (default) or `"json"` (upstream-compat).
`options.maxStates` — capacity of the underlying state store. Default `100`.
Throws `BitpackerError(BITPACKER.DELTA_INVALID_CONFIG)` on invalid config.
### `detectChanges(current, schema, stateKey?) → FieldChange[]`
Returns the ordered list of fields whose `current` value differs from the stored snapshot under `stateKey`. First call (no snapshot) returns `[]`. Schema with more keys than `maxFieldCount` throws `BITPACKER.DELTA_FIELD_LIMIT_EXCEEDED`.
### `storePreviousState(state, stateKey?) → void`
Stores a deep-cloned snapshot of `state` for the next diff.
### `getPreviousState(stateKey?) → unknown | undefined`
### `hasPreviousState(stateKey?) → boolean`
Snapshot inspection helpers.
### `calculateSavings(originalBits, deltaBits) → number`
### `shouldUseDelta(originalBits, deltaBits) → boolean`
Bandwidth-savings calculation against the configured `threshold`.
### `clear() → void`
### `clearState(stateKey) → void`
### `getConfig() → Readonly<Required<DeltaConfig>>`
Lifecycle and inspection helpers.
## Implementation Notes
The structural-equality walker treats every `ArrayBuffer.isView` value (every typed-array variant) as a byte-by-byte comparison. `Date` instances compare via `getTime()`. Plain objects compare key-sets first (size + key membership), then recurse on values. Arrays compare length-then-elements. This matches `BitPackerSchema` semantics: two values that pack to identical bytes are equal.
The `"json"` equality mode is provided for upstream byte-compat but has known limitations: `JSON.stringify({ a: 1, b: 2 })` and `JSON.stringify({ b: 2, a: 1 })` produce different strings even though the values are semantically equal in a `BitPackerSchema` context. Consumers porting from upstream should switch to `"structural"` once they've verified no callers depend on key-order-sensitive equality.
The `maxFieldCount` cap is a defence: a hostile peer that sends a schema with thousands of fields could exhaust memory if the bitmask were unbounded. The default (64) accommodates typical state-sync workloads; consumers with larger schemas raise it explicitly.
Threshold semantics: `shouldUseDelta` returns `true` when `(originalBits - deltaBits) / originalBits * 100 >= threshold`. A `threshold` of `0` means "always prefer delta when smaller"; a `threshold` of `25` means "only prefer delta if it saves at least a quarter of the bits."

View File

@@ -0,0 +1,148 @@
/**
* Spec-conformance tests for DeltaManager. These tests pin the behaviours
* documented in `openspec/changes/bitpacker-stub-completion/specs/bitpacker-delta/spec.md`
* and the audit-driven additions: invalid-config rejection via Zod, no
* module-scope determinism violations.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { BitPackerSchema } from "@suaveplan/types";
import { type DeltaConfig, DeltaManager } from "./delta-manager.js";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const SRC_ROOT = join(__dirname, "..");
describe("DeltaManager spec conformance", () => {
it("rejects invalid DeltaConfig with DELTA_INVALID_CONFIG (unknown mode)", {
timeout: 5000,
}, () => {
const bad = { mode: "magic" } as unknown as DeltaConfig;
expect(() => new DeltaManager(bad)).toThrow(BitpackerError);
try {
new DeltaManager(bad);
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.DELTA_INVALID_CONFIG
);
}
});
it("rejects invalid DeltaConfig with DELTA_INVALID_CONFIG (unknown key)", {
timeout: 5000,
}, () => {
const bad = {
mode: "field-level",
wrongKey: 123,
} as unknown as DeltaConfig;
expect(() => new DeltaManager(bad)).toThrow(BitpackerError);
try {
new DeltaManager(bad);
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.DELTA_INVALID_CONFIG
);
}
});
it("rejects invalid DeltaConfig with DELTA_INVALID_CONFIG (out-of-range threshold)", {
timeout: 5000,
}, () => {
const bad = {
mode: "field-level",
threshold: 150,
} as unknown as DeltaConfig;
expect(() => new DeltaManager(bad)).toThrow(BitpackerError);
});
it("uses structural equality by default (key-order-insensitive on nested objects)", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ o: { a: 1, b: 2 } });
// Different declaration order, same data:
const changes = dm.detectChanges({ o: { b: 2, a: 1 } }, schema);
expect(changes).toEqual([]);
});
it("opts into JSON equality when equalityMode is 'json'", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({
mode: "field-level",
equalityMode: "json",
});
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ o: { a: 1, b: 2 } });
const changes = dm.detectChanges({ o: { b: 2, a: 1 } }, schema);
// JSON.stringify is sensitive to key order — flagged as change.
expect(changes).toHaveLength(1);
});
it("no module-scope Date.now in implementation files", {
timeout: 5000,
}, () => {
const matches = grepImpl(/Date\.now\(\)/g);
expect(matches, JSON.stringify(matches, null, 2)).toEqual([]);
});
it("no module-scope Math.random in implementation files", {
timeout: 5000,
}, () => {
const matches = grepImpl(/Math\.random\(\)/g);
expect(matches, JSON.stringify(matches, null, 2)).toEqual([]);
});
});
/**
* Recursively walk `src/`, collect every `.ts` that's NOT a test file, and
* return all matches of `pattern` in non-comment lines. Used to verify the
* determinism rule. Comment lines are skipped because docblocks legitimately
* mention the forbidden tokens to explain the rule.
*/
function grepImpl(
pattern: RegExp
): readonly { file: string; line: number; text: string }[] {
const matches: { file: string; line: number; text: string }[] = [];
const visit = (dir: string): void => {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
visit(full);
continue;
}
if (!entry.endsWith(".ts")) continue;
if (entry.endsWith(".test.ts")) continue;
if (entry.endsWith(".spec-conformance.test.ts")) continue;
const content = readFileSync(full, "utf8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const raw = lines[i] ?? "";
if (isCommentLine(raw)) continue;
if (pattern.test(raw)) {
matches.push({
file: full,
line: i + 1,
text: raw.trim(),
});
}
pattern.lastIndex = 0; // reset for /g
}
}
};
visit(SRC_ROOT);
return matches;
}
function isCommentLine(line: string): boolean {
const trimmed = line.trim();
if (trimmed === "") return true;
if (trimmed.startsWith("//")) return true;
if (trimmed.startsWith("*")) return true; // inside /** ... */
if (trimmed.startsWith("/*")) return true;
return false;
}

View File

@@ -0,0 +1,316 @@
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { BitPackerSchema } from "@suaveplan/types";
import { DeltaManager } from "./delta-manager.js";
describe("DeltaManager", () => {
it("returns empty array on first call for a state key", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { a: "u8" } as Record<string, BitPackerSchema>;
const result = dm.detectChanges({ a: 1 }, schema);
expect(result).toEqual([]);
});
it("detects changed fields in schema order", { timeout: 5000 }, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = {
a: "u8",
b: "-32",
c: "u16",
} as Record<string, BitPackerSchema>;
dm.storePreviousState({ a: 1, b: "x", c: 100 });
const changes = dm.detectChanges({ a: 1, b: "y", c: 200 }, schema);
expect(changes).toHaveLength(2);
expect(changes[0]).toEqual({
index: 1,
key: "b",
previousValue: "x",
currentValue: "y",
});
expect(changes[1]).toEqual({
index: 2,
key: "c",
previousValue: 100,
currentValue: 200,
});
});
it("detects no changes when values are identical (primitives)", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { a: "u8" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ a: 1 });
expect(dm.detectChanges({ a: 1 }, schema)).toEqual([]);
});
it("structural equality treats different key orders as equal in nested objects", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { pos: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ pos: { x: 1, y: 2 } });
const changes = dm.detectChanges({ pos: { y: 2, x: 1 } }, schema);
expect(changes).toEqual([]);
});
it("json equality mode flags key-order-different nested objects as changes", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({
mode: "field-level",
equalityMode: "json",
});
const schema = { pos: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ pos: { x: 1, y: 2 } });
const changes = dm.detectChanges({ pos: { y: 2, x: 1 } }, schema);
// JSON stringify is sensitive to key order — upstream-compat mode.
expect(changes).toHaveLength(1);
});
it("throws DELTA_FIELD_LIMIT_EXCEEDED when schema exceeds maxFieldCount", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({
mode: "field-level",
maxFieldCount: 4,
});
const schema = {
a: "u8",
b: "u8",
c: "u8",
d: "u8",
e: "u8",
} as Record<string, BitPackerSchema>;
dm.storePreviousState({ a: 1, b: 2, c: 3, d: 4, e: 5 });
expect(() =>
dm.detectChanges({ a: 1, b: 2, c: 3, d: 4, e: 5 }, schema)
).toThrow(BitpackerError);
try {
dm.detectChanges({ a: 1, b: 2, c: 3, d: 4, e: 5 }, schema);
} catch (e) {
expect(e).toBeInstanceOf(BitpackerError);
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.DELTA_FIELD_LIMIT_EXCEEDED
);
expect((e as BitpackerError).context).toMatchObject({
fieldCount: 5,
max: 4,
});
}
});
it("scopes diffs by stateKey — multiple peers don't cross-contaminate", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { v: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ v: 100 }, "peer-A");
dm.storePreviousState({ v: 200 }, "peer-B");
// Diffing peer-A's current against peer-A's snapshot:
expect(dm.detectChanges({ v: 105 }, schema, "peer-A")).toHaveLength(1);
// peer-B's current value happens to equal its snapshot:
expect(dm.detectChanges({ v: 200 }, schema, "peer-B")).toEqual([]);
});
it("storePreviousState deep-clones the snapshot", { timeout: 5000 }, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = {
obj: "u32",
} as Record<string, BitPackerSchema>;
const original = { obj: { x: 1 } };
dm.storePreviousState(original);
(original.obj as { x: number }).x = 999;
// Diff against current (matches the original pre-mutation state):
const changes = dm.detectChanges({ obj: { x: 1 } }, schema);
expect(changes).toEqual([]);
});
it("calculateSavings is correct", { timeout: 5000 }, () => {
const dm = new DeltaManager({ mode: "field-level" });
expect(dm.calculateSavings(100, 25)).toBe(75);
expect(dm.calculateSavings(100, 100)).toBe(0);
expect(dm.calculateSavings(0, 0)).toBe(0); // /0 guard
});
it("shouldUseDelta respects threshold", { timeout: 5000 }, () => {
const dm = new DeltaManager({
mode: "field-level",
threshold: 25,
});
expect(dm.shouldUseDelta(100, 80)).toBe(false); // 20% savings, < 25%
expect(dm.shouldUseDelta(100, 75)).toBe(true); // 25% savings, exactly threshold
expect(dm.shouldUseDelta(100, 50)).toBe(true); // 50% savings
});
it("getPreviousState / hasPreviousState reflect storePreviousState", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
expect(dm.hasPreviousState()).toBe(false);
expect(dm.getPreviousState()).toBeUndefined();
dm.storePreviousState({ x: 1 });
expect(dm.hasPreviousState()).toBe(true);
expect(dm.getPreviousState()).toEqual({ x: 1 });
dm.storePreviousState({ x: 2 }, "peer-A");
expect(dm.hasPreviousState("peer-A")).toBe(true);
expect(dm.getPreviousState("peer-A")).toEqual({ x: 2 });
expect(dm.hasPreviousState("missing")).toBe(false);
expect(dm.getPreviousState("missing")).toBeUndefined();
});
it("clear drops all snapshots", { timeout: 5000 }, () => {
const dm = new DeltaManager({ mode: "field-level" });
dm.storePreviousState({ a: 1 }, "k1");
dm.storePreviousState({ a: 2 }, "k2");
dm.clear();
expect(dm.hasPreviousState("k1")).toBe(false);
expect(dm.hasPreviousState("k2")).toBe(false);
});
it("clearState drops one snapshot", { timeout: 5000 }, () => {
const dm = new DeltaManager({ mode: "field-level" });
dm.storePreviousState({ a: 1 }, "k1");
dm.storePreviousState({ a: 2 }, "k2");
dm.clearState("k1");
expect(dm.hasPreviousState("k1")).toBe(false);
expect(dm.hasPreviousState("k2")).toBe(true);
});
it("constructor with no args defaults to { mode: 'none' }", {
timeout: 5000,
}, () => {
const dm = new DeltaManager();
expect(dm.getConfig().mode).toBe("none");
});
it("structural equality treats null vs object as different", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { v: "u32" } as Record<string, BitPackerSchema>;
// First store: previous = { v: { x: 1 } }
dm.storePreviousState({ v: { x: 1 } });
// Current has v: null — null vs object asymmetric branches
const changes = dm.detectChanges(
{ v: null as unknown as object },
schema
);
expect(changes).toHaveLength(1);
});
it("structural equality treats different typeof as different", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { v: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ v: 1 });
const changes = dm.detectChanges(
{ v: "1" as unknown as number },
schema
);
expect(changes).toHaveLength(1);
});
it("structural equality detects typed-array length mismatch", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { buf: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ buf: new Uint8Array([1, 2, 3]) });
const changes = dm.detectChanges(
{ buf: new Uint8Array([1, 2]) },
schema
);
expect(changes).toHaveLength(1);
});
it("structural equality detects object key-count mismatch", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ o: { a: 1, b: 2 } });
const changes = dm.detectChanges(
{ o: { a: 1 } as unknown as { a: number; b: number } },
schema
);
expect(changes).toHaveLength(1);
});
it("structural equality detects object disjoint-keys mismatch", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ o: { a: 1, b: 2 } });
// Same key count but different keys
const changes = dm.detectChanges(
{
o: { a: 1, c: 2 } as unknown as { a: number; b: number },
},
schema
);
expect(changes).toHaveLength(1);
});
it("structural equality detects object same-keys-different-values", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ o: { a: 1, b: 2 } });
// Same keys, different value for `b`
const changes = dm.detectChanges({ o: { a: 1, b: 999 } }, schema);
expect(changes).toHaveLength(1);
});
it("getConfig returns the resolved config", { timeout: 5000 }, () => {
const dm = new DeltaManager({ mode: "xor", threshold: 50 });
const cfg = dm.getConfig();
expect(cfg.mode).toBe("xor");
expect(cfg.threshold).toBe(50);
expect(cfg.xorNumbers).toBe(true); // default
expect(cfg.diffStrings).toBe(false); // default
expect(cfg.maxFieldCount).toBe(64); // default
expect(cfg.stateKey).toBe("default"); // default
expect(cfg.equalityMode).toBe("structural"); // default
});
it("structural equality compares typed arrays element-wise", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { buf: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ buf: new Uint8Array([1, 2, 3]) });
// Same content → no change
expect(
dm.detectChanges({ buf: new Uint8Array([1, 2, 3]) }, schema)
).toEqual([]);
// Different content → one change
expect(
dm.detectChanges({ buf: new Uint8Array([1, 2, 4]) }, schema)
).toHaveLength(1);
});
it("structural equality compares arrays length-then-elements", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { arr: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ arr: [1, 2, 3] });
expect(dm.detectChanges({ arr: [1, 2, 3] }, schema)).toEqual([]);
expect(dm.detectChanges({ arr: [1, 2] }, schema)).toHaveLength(1); // length differs
expect(dm.detectChanges({ arr: [1, 2, 4] }, schema)).toHaveLength(1); // element differs
});
});

View File

@@ -0,0 +1,286 @@
/**
* Field-level delta detector for bit-packed objects.
*
* Ported from suaveplan upstream `core/delta/delta-manager.ts`. Genesis-side
* hardening:
* - Replaces upstream's `Date.now()` with an injectable `Clock` (CLAUDE.md
* determinism rule).
* - Replaces upstream's `JSON.stringify`-based equality (which is sensitive
* to key order) with a structural walker that respects `BitPackerSchema`
* field order. The legacy stringify behaviour is available behind opt-in
* `equalityMode: "json"` for byte-compat with upstream payloads.
* - Replaces upstream's plain-`Error` throws with `BitpackerError` codes.
* - Validates `DeltaConfig` via Zod at construction; unknown fields and
* invalid `mode` values are rejected with `BITPACKER.DELTA_INVALID_CONFIG`.
*/
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import type { BitPackerSchema } from "@suaveplan/types";
import z from "zod";
import { DeltaStateManager } from "../delta-state/delta-state.js";
const DEFAULT_STATE_KEY = "default";
const DEFAULT_MAX_FIELD_COUNT = 64;
const DEFAULT_THRESHOLD = 0;
/**
* One field that changed between the previous and current state. Indexed by
* `Object.keys(schema)` order so encoders can rely on stable bit offsets.
*/
export interface FieldChange {
readonly index: number;
readonly key: string;
readonly previousValue: unknown;
readonly currentValue: unknown;
}
/**
* Configuration accepted by `new DeltaManager`. Validated via Zod at
* construction; unknown keys are rejected.
*/
export interface DeltaConfig {
/** Strategy mode. `"none"` returns the value unchanged on every detect. */
readonly mode: "none" | "xor" | "string-diff" | "field-level";
/** XOR numeric fields when packing the delta. Default `true`. */
readonly xorNumbers?: boolean;
/** Diff string fields when packing. Default `false`. */
readonly diffStrings?: boolean;
/** Bandwidth-saving threshold (% bits saved) before delta is preferred. Default `0`. */
readonly threshold?: number;
/** Maximum fields per object the field-level mask can address. Default `64`. */
readonly maxFieldCount?: number;
/** Default state key for `detectChanges` calls without one. */
readonly stateKey?: string;
/**
* `"structural"` (default) walks the value respecting schema field order.
* `"json"` falls back to `JSON.stringify` equality (upstream-compat mode).
*/
readonly equalityMode?: "structural" | "json";
}
const DeltaConfigSchema = z
.object({
mode: z.enum(["none", "xor", "string-diff", "field-level"]),
xorNumbers: z.boolean().optional(),
diffStrings: z.boolean().optional(),
threshold: z.number().min(0).max(100).optional(),
maxFieldCount: z.number().int().min(1).optional(),
stateKey: z.string().min(1).optional(),
equalityMode: z.enum(["structural", "json"]).optional(),
})
.strict();
type ResolvedConfig = Required<DeltaConfig>;
/**
* Detects field-level changes between successive states of an object.
*
* Stateful: each `DeltaManager` owns a `DeltaStateManager` keyed by string.
* Consumers store one snapshot per logical state stream (per peer, per
* session, etc.) and call `detectChanges(current, schema)` on each tick to
* get an ordered `FieldChange[]`. The returned indices match
* `Object.keys(schema)` order so encoders can pack a fixed-width bitmask.
*/
export class DeltaManager {
private readonly stateManager: DeltaStateManager;
private readonly config: ResolvedConfig;
public constructor(
config: DeltaConfig = { mode: "none" },
options: DeltaManagerOptions = {}
) {
const parsed = DeltaConfigSchema.safeParse(config);
if (!parsed.success) {
throw new BitpackerError(
BITPACKER_CODES.DELTA_INVALID_CONFIG,
`Invalid DeltaConfig: ${parsed.error.message}`,
{ context: { issues: parsed.error.issues } }
);
}
this.stateManager = new DeltaStateManager(options.maxStates);
this.config = {
mode: parsed.data.mode,
xorNumbers: parsed.data.xorNumbers ?? true,
diffStrings: parsed.data.diffStrings ?? false,
threshold: parsed.data.threshold ?? DEFAULT_THRESHOLD,
maxFieldCount: parsed.data.maxFieldCount ?? DEFAULT_MAX_FIELD_COUNT,
stateKey: parsed.data.stateKey ?? DEFAULT_STATE_KEY,
equalityMode: parsed.data.equalityMode ?? "structural",
};
}
/**
* Compute the ordered list of fields whose value changed between the
* stored previous state (under `stateKey ?? config.stateKey`) and
* `current`. On the first call for a state key (no previous stored), the
* result is an empty array. Field indices match `Object.keys(schema)`.
*/
public detectChanges(
current: Record<string, unknown>,
schema: Record<string, BitPackerSchema>,
stateKey?: string
): FieldChange[] {
const fieldKeys = Object.keys(schema);
if (fieldKeys.length > this.config.maxFieldCount) {
throw new BitpackerError(
BITPACKER_CODES.DELTA_FIELD_LIMIT_EXCEEDED,
`Schema has ${fieldKeys.length} fields; maxFieldCount is ${this.config.maxFieldCount}`,
{
context: {
fieldCount: fieldKeys.length,
max: this.config.maxFieldCount,
},
}
);
}
const key = stateKey ?? this.config.stateKey;
const previous = this.stateManager.getPreviousState(key) as
| Record<string, unknown>
| undefined;
if (previous === undefined) {
return [];
}
const changes: FieldChange[] = [];
for (let index = 0; index < fieldKeys.length; index++) {
// `Object.keys` always returns defined strings, so the
// index-access is sound; cast tames `noUncheckedIndexedAccess`.
const fieldKey = fieldKeys[index] as string;
const currentValue = current[fieldKey];
const previousValue = previous[fieldKey];
if (!this.valuesEqual(currentValue, previousValue)) {
changes.push({
index,
key: fieldKey,
previousValue,
currentValue,
});
}
}
return changes;
}
/**
* Store `state` as the new "previous" snapshot under `key`. The next
* `detectChanges` call will diff against this. Snapshot is deep-cloned by
* the underlying `DeltaStateManager`.
*/
public storePreviousState(state: unknown, stateKey?: string): void {
this.stateManager.storePreviousState(
stateKey ?? this.config.stateKey,
state
);
}
public getPreviousState(stateKey?: string): unknown | undefined {
return this.stateManager.getPreviousState(
stateKey ?? this.config.stateKey
);
}
public hasPreviousState(stateKey?: string): boolean {
return this.stateManager.hasPreviousState(
stateKey ?? this.config.stateKey
);
}
/**
* Compute a savings percentage. `0%` means delta is the same size as
* full; `100%` means delta is empty. Used by `shouldUseDelta` to decide
* whether the delta is worth sending.
*/
public calculateSavings(originalBits: number, deltaBits: number): number {
if (originalBits === 0) return 0;
return ((originalBits - deltaBits) / originalBits) * 100;
}
public shouldUseDelta(originalBits: number, deltaBits: number): boolean {
return (
this.calculateSavings(originalBits, deltaBits) >=
this.config.threshold
);
}
public clear(): void {
this.stateManager.clear();
}
public clearState(stateKey: string): void {
this.stateManager.clearState(stateKey);
}
public getConfig(): Readonly<ResolvedConfig> {
return this.config;
}
/**
* Structural deep equality respecting bit-packer field semantics.
*
* - In `"json"` mode, falls back to `JSON.stringify` equality
* (upstream-compat — sensitive to key order and not safe for typed
* arrays without custom replacer).
* - In `"structural"` (default), walks values: primitives compare via
* `===`, typed arrays compare element-wise, arrays compare deeply,
* plain objects compare key-set-then-values.
*
* Note: Maps and Sets are NOT supported in structural mode (they would
* never be packed via a `BitPackerSchema` anyway). If supplied they
* compare by reference (identity).
*/
private valuesEqual(a: unknown, b: unknown): boolean {
if (this.config.equalityMode === "json") {
return JSON.stringify(a) === JSON.stringify(b);
}
if (a === b) return true;
if (a == null || b == null) return false;
if (typeof a !== typeof b) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!this.valuesEqual(a[i], b[i])) return false;
}
return true;
}
if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) {
const aArr = new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
const bArr = new Uint8Array(b.buffer, b.byteOffset, b.byteLength);
if (aArr.length !== bArr.length) return false;
for (let i = 0; i < aArr.length; i++) {
if (aArr[i] !== bArr[i]) return false;
}
return true;
}
if (typeof a === "object" && typeof b === "object") {
const ao = a as Record<string, unknown>;
const bo = b as Record<string, unknown>;
const aKeys = Object.keys(ao);
const bKeys = Object.keys(bo);
if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) {
if (!Object.hasOwn(bo, key)) return false;
if (!this.valuesEqual(ao[key], bo[key])) return false;
}
return true;
}
return false;
}
}
/**
* Construction-time options for `DeltaManager`. Distinct from runtime
* `DeltaConfig` (which controls behaviour); this controls the underlying
* state-store capacity.
*/
export interface DeltaManagerOptions {
/** Capacity of the underlying `DeltaStateManager`. Default 100. */
readonly maxStates?: number;
}

View File

@@ -0,0 +1,90 @@
# DeltaStateManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DeltaStateManager` keeps deep-cloned snapshots of previous values per state key, so that a delta encoder can diff the current value against its predecessor without worrying about caller-side mutations. It is the storage layer for `DeltaManager`; consumers usually go through `DeltaManager` rather than calling this class directly.
The store is bounded: an LRU eviction policy drops the oldest key once the configured capacity is reached. Default capacity is 100 keys, which is plenty for most workloads (one key per per-peer state stream). Re-storing under an existing key updates the value in place without advancing its LRU position — this matches upstream semantics so that frequently-updated keys do not unfairly evict rarely-updated ones.
## Features
- Deep-cloning on store: `Date`, all typed-array variants (`Uint8Array`, `Int8Array`, `Uint16Array`, `Int16Array`, `Uint32Array`, `Int32Array`, `Float32Array`, `Float64Array`), arrays, and plain objects are all isolated from caller mutations.
- Insertion-order LRU eviction; `maxStates` configurable (default 100).
- `clear()`, `clearState(key)`, `getStateCount()`, `getMaxStates()` accessors for inspection and reset.
- Zero `Math.random` / `Date.now` calls — fully deterministic given identical inputs.
## Usage: Basic Example
```typescript
import { DeltaStateManager } from "@suaveplan/bitpacker-delta";
const states = new DeltaStateManager(100);
states.storePreviousState("player-1", { x: 0, y: 0 });
const previous = states.getPreviousState("player-1");
// → { x: 0, y: 0 }
```
## Usage: Advanced Example
```typescript
import { DeltaStateManager } from "@suaveplan/bitpacker-delta";
const states = new DeltaStateManager(3);
states.storePreviousState("a", { v: 1 });
states.storePreviousState("b", { v: 2 });
states.storePreviousState("c", { v: 3 });
states.storePreviousState("d", { v: 4 }); // evicts "a"
states.hasPreviousState("a"); // → false (evicted)
states.hasPreviousState("d"); // → true
// Mutating the original after storing does NOT touch the snapshot:
const original = { x: 0 };
states.storePreviousState("e", original);
original.x = 999;
states.getPreviousState("e"); // → { x: 0 }
```
## API Reference
### `new DeltaStateManager(maxStates?: number)`
- `maxStates` — positive integer; default `100`. Throws `RangeError` if non-integer or `< 1`.
### `storePreviousState(key: string, state: unknown): void`
Stores a deep-cloned copy of `state` under `key`. If `key` already exists, the value is updated in place; LRU position is unchanged. If the store is at capacity and this is a new key, the oldest key is evicted.
### `getPreviousState(key: string): unknown | undefined`
Returns the stored snapshot, or `undefined` if the key has never been stored or has been evicted.
### `hasPreviousState(key: string): boolean`
True if a snapshot exists for `key`.
### `clear(): void`
Drops every snapshot.
### `clearState(key: string): void`
Drops the snapshot for one key. No-op if absent.
### `getStateCount(): number`
Number of stored snapshots.
### `getMaxStates(): number`
Configured maximum capacity.
## Implementation Notes
The deep-clone walker handles the eight typed-array variants explicitly because the generic object-clone path (`Object.hasOwn` over keys) does not preserve `Uint8Array`-vs-plain-array distinction. Maps and Sets are intentionally NOT cloned: delta-state semantics assume keys are strings and values are plain data; if a consumer needs `Map`/`Set`, they should canonicalise to a plain object first.
Performance: insertion is O(1) amortised; eviction is O(1) (Array `.shift()` on a small bounded array). Lookup is O(1). The `stateKeys` array tracks insertion order without using a `Map`'s native iteration (some legacy environments don't preserve insertion order — though every modern runtime does — so the explicit array is defensive).

View File

@@ -0,0 +1,160 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DeltaStateManager } from "./delta-state.js";
describe("DeltaStateManager", () => {
it("deep-clones stored states to isolate from caller mutation", {
timeout: 5000,
}, () => {
const states = new DeltaStateManager();
const original = { x: 1, nested: { y: 2 } };
states.storePreviousState("k", original);
(original as { x: number }).x = 999;
(original as { nested: { y: number } }).nested.y = 999;
const snapshot = states.getPreviousState("k") as {
x: number;
nested: { y: number };
};
expect(snapshot.x).toBe(1);
expect(snapshot.nested.y).toBe(2);
});
it("evicts oldest state when maxStates exceeded", { timeout: 5000 }, () => {
const states = new DeltaStateManager(3);
states.storePreviousState("a", 1);
states.storePreviousState("b", 2);
states.storePreviousState("c", 3);
states.storePreviousState("d", 4);
expect(states.hasPreviousState("a")).toBe(false);
expect(states.hasPreviousState("b")).toBe(true);
expect(states.hasPreviousState("c")).toBe(true);
expect(states.hasPreviousState("d")).toBe(true);
expect(states.getStateCount()).toBe(3);
});
it("re-store under existing key preserves LRU position", {
timeout: 5000,
}, () => {
const states = new DeltaStateManager(3);
states.storePreviousState("a", 1);
states.storePreviousState("b", 2);
states.storePreviousState("c", 3);
// re-store "a" — should NOT advance LRU
states.storePreviousState("a", 100);
// adding a new key should still evict "a" (oldest)
states.storePreviousState("d", 4);
expect(states.hasPreviousState("a")).toBe(false);
expect(states.hasPreviousState("b")).toBe(true);
expect(states.hasPreviousState("c")).toBe(true);
expect(states.hasPreviousState("d")).toBe(true);
});
it("re-store updates value in place", { timeout: 5000 }, () => {
const states = new DeltaStateManager();
states.storePreviousState("k", 1);
states.storePreviousState("k", 2);
expect(states.getPreviousState("k")).toBe(2);
expect(states.getStateCount()).toBe(1);
});
it("clear drops all states", { timeout: 5000 }, () => {
const states = new DeltaStateManager();
states.storePreviousState("a", 1);
states.storePreviousState("b", 2);
states.clear();
expect(states.getStateCount()).toBe(0);
expect(states.hasPreviousState("a")).toBe(false);
expect(states.hasPreviousState("b")).toBe(false);
});
it("clearState drops only the named state", { timeout: 5000 }, () => {
const states = new DeltaStateManager();
states.storePreviousState("a", 1);
states.storePreviousState("b", 2);
states.clearState("a");
expect(states.hasPreviousState("a")).toBe(false);
expect(states.hasPreviousState("b")).toBe(true);
});
it("clearState on absent key is a no-op", { timeout: 5000 }, () => {
const states = new DeltaStateManager();
states.storePreviousState("a", 1);
states.clearState("missing");
expect(states.hasPreviousState("a")).toBe(true);
expect(states.getStateCount()).toBe(1);
});
it("default maxStates is 100", { timeout: 5000 }, () => {
const states = new DeltaStateManager();
expect(states.getMaxStates()).toBe(100);
});
it("deep-clones Date instances to isolate from caller mutation", {
timeout: 5000,
}, () => {
const states = new DeltaStateManager();
const original = new Date("2026-01-01T00:00:00Z");
states.storePreviousState("k", original);
const snapshot = states.getPreviousState("k") as Date;
expect(snapshot).toBeInstanceOf(Date);
expect(snapshot.getTime()).toBe(original.getTime());
// Snapshot is a copy:
expect(snapshot).not.toBe(original);
});
it("deep-clones Uint8Array buffers to isolate from caller mutation", {
timeout: 5000,
}, () => {
const states = new DeltaStateManager();
const original = new Uint8Array([1, 2, 3]);
states.storePreviousState("k", original);
original[0] = 99;
const snapshot = states.getPreviousState("k") as Uint8Array;
expect(snapshot).toBeInstanceOf(Uint8Array);
expect(Array.from(snapshot)).toEqual([1, 2, 3]);
});
it("deep-clones every typed-array variant", { timeout: 5000 }, () => {
const states = new DeltaStateManager();
const variants: ReadonlyArray<readonly [string, unknown]> = [
["i8", new Int8Array([1, 2])],
["u16", new Uint16Array([300])],
["i16", new Int16Array([-300])],
["u32", new Uint32Array([70000])],
["i32", new Int32Array([-70000])],
["f32", new Float32Array([1.5])],
["f64", new Float64Array([1.5])],
];
for (const [k, v] of variants) {
states.storePreviousState(k, v);
const snap = states.getPreviousState(k);
expect(snap).toBeInstanceOf(
(v as ArrayBufferView).constructor as new () => unknown
);
expect(snap).not.toBe(v);
}
});
it("deep-clones nested arrays and objects", { timeout: 5000 }, () => {
const states = new DeltaStateManager();
const original = {
arr: [1, 2, { nested: true }],
obj: { a: { b: { c: 1 } } },
};
states.storePreviousState("k", original);
(original.arr[2] as { nested: boolean }).nested = false;
(original.obj.a.b as { c: number }).c = 999;
const snap = states.getPreviousState("k") as typeof original;
expect((snap.arr[2] as { nested: boolean }).nested).toBe(true);
expect(snap.obj.a.b.c).toBe(1);
});
it("rejects non-positive maxStates", { timeout: 5000 }, () => {
expect(() => new DeltaStateManager(0)).toThrow(RangeError);
expect(() => new DeltaStateManager(-1)).toThrow(RangeError);
expect(() => new DeltaStateManager(1.5)).toThrow(RangeError);
});
});

View File

@@ -0,0 +1,149 @@
/**
* Manages previous state snapshots for delta compression.
*
* Ported from suaveplan upstream `core/delta/delta-state.ts`. Genesis-side
* hardening: no module-scope `Math.random` or `Date.now`; deep-cloning
* preserved verbatim (caller mutations of the original do not affect the
* stored snapshot); LRU eviction by insertion order with a configurable
* `maxStates` capacity (default 100).
*/
const DEFAULT_MAX_STATES = 100;
/**
* Stores per-key snapshots of previous values for delta diffing. Insertion
* order determines eviction order: the oldest key is evicted when the store
* grows past `maxStates`. Re-storing under an existing key updates the value
* in place and does NOT advance the LRU position (matches upstream behaviour).
*/
export class DeltaStateManager {
private readonly previousStates = new Map<string, unknown>();
private readonly stateKeys: string[] = [];
private readonly maxStates: number;
public constructor(maxStates: number = DEFAULT_MAX_STATES) {
if (!Number.isInteger(maxStates) || maxStates < 1) {
throw new RangeError(
`DeltaStateManager maxStates must be a positive integer; got ${String(maxStates)}`
);
}
this.maxStates = maxStates;
}
/**
* Store a deep-cloned snapshot of `state` under `key`. If `key` already
* exists, its value is updated in place; LRU position is preserved.
*/
public storePreviousState(key: string, state: unknown): void {
const existed = this.previousStates.has(key);
this.previousStates.set(key, deepClone(state));
if (!existed) {
this.stateKeys.push(key);
}
// Evict oldest if we've grown past capacity. The `> maxStates` check
// above guarantees `stateKeys` is non-empty here, so `shift()` never
// returns undefined — the cast is sound.
if (this.stateKeys.length > this.maxStates) {
const oldestKey = this.stateKeys.shift() as string;
this.previousStates.delete(oldestKey);
}
}
/**
* Look up the snapshot stored under `key`, or `undefined` if no snapshot
* has been stored yet (or the key has been evicted).
*/
public getPreviousState(key: string): unknown | undefined {
return this.previousStates.get(key);
}
public hasPreviousState(key: string): boolean {
return this.previousStates.has(key);
}
/**
* Drop every stored snapshot. The instance can be reused.
*/
public clear(): void {
this.previousStates.clear();
this.stateKeys.length = 0;
}
/**
* Drop the snapshot for one specific key. No-op if the key is absent.
*/
public clearState(key: string): void {
this.previousStates.delete(key);
const index = this.stateKeys.indexOf(key);
if (index !== -1) {
this.stateKeys.splice(index, 1);
}
}
public getStateCount(): number {
return this.previousStates.size;
}
/**
* Read-only view of the configured maximum capacity. Useful for tests.
*/
public getMaxStates(): number {
return this.maxStates;
}
}
/**
* Deep-clone a value to isolate stored snapshots from caller mutations.
* Handles primitives, Date, every typed-array view, plain objects, and
* arrays. Maps and Sets are NOT cloned (out of scope for delta state — if a
* consumer needs them, they should canonicalise to a plain object first).
*/
function deepClone(value: unknown): unknown {
if (value === null || typeof value !== "object") {
return value;
}
if (value instanceof Date) {
return new Date(value.getTime());
}
if (Array.isArray(value)) {
return value.map(deepClone);
}
if (value instanceof Uint8Array) {
return new Uint8Array(value);
}
if (value instanceof Int8Array) {
return new Int8Array(value);
}
if (value instanceof Uint16Array) {
return new Uint16Array(value);
}
if (value instanceof Int16Array) {
return new Int16Array(value);
}
if (value instanceof Uint32Array) {
return new Uint32Array(value);
}
if (value instanceof Int32Array) {
return new Int32Array(value);
}
if (value instanceof Float32Array) {
return new Float32Array(value);
}
if (value instanceof Float64Array) {
return new Float64Array(value);
}
const cloned: Record<string, unknown> = {};
const source = value as Record<string, unknown>;
// `Object.keys` returns own enumerable keys only — no need for an
// inner `Object.hasOwn` guard.
for (const key of Object.keys(source)) {
cloned[key] = deepClone(source[key]);
}
return cloned;
}

View File

@@ -0,0 +1,96 @@
/**
* Tests for delta encoders (string-diff, xor-encoder, numberXor).
*
* Note: `DeltaManager` and `DeltaStateManager` tests have been moved to their
* own module directories under `delta-manager/` and `delta-state/`. This file
* covers only the standalone codecs.
*/
import { describe, expect, test } from "@suaveplan/testing/runner";
import {
applyStringDiff,
applyXorDelta,
calculateDeltaBitSize,
calculateDiffSavings,
calculateStringDiff,
calculateXorDelta,
isXorEfficient,
numberXor,
shouldUseDiff,
} from "./index.js";
describe("numberXor", () => {
test("XOR of identical numbers is zero-ish", () => {
const result = numberXor(42, 42);
expect(result).toBe(0);
});
test("XOR roundtrips", () => {
const a = Math.PI;
const b = Math.E;
const xor = numberXor(a, b);
expect(numberXor(xor, b)).toBeCloseTo(a);
});
});
describe("xor encoder", () => {
test("calculateXorDelta", () => {
expect(calculateXorDelta(0b1010, 0b1100)).toBe(0b0110);
});
test("applyXorDelta roundtrips", () => {
const prev = 12345;
const curr = 54321;
const delta = calculateXorDelta(curr, prev);
expect(applyXorDelta(prev, delta)).toBe(curr);
});
test("calculateDeltaBitSize", () => {
expect(calculateDeltaBitSize(0)).toBe(1);
expect(calculateDeltaBitSize(1)).toBe(1);
expect(calculateDeltaBitSize(2)).toBe(2);
expect(calculateDeltaBitSize(255)).toBe(8);
});
test("isXorEfficient", () => {
expect(isXorEfficient(10, 10, 8)).toBe(true);
expect(isXorEfficient(0, 255, 4)).toBe(false);
});
});
describe("string diff", () => {
test("identical strings have full prefix", () => {
const diff = calculateStringDiff("hello", "hello");
expect(diff.prefixLen).toBe(5);
expect(diff.middleLen).toBe(0);
});
test("empty previous returns full current", () => {
const diff = calculateStringDiff("", "hello");
expect(diff.middle).toBe("hello");
});
test("empty current returns zero diff", () => {
const diff = calculateStringDiff("hello", "");
expect(diff.middle).toBe("");
expect(diff.prefixLen).toBe(0);
});
test("suffix-only change", () => {
const diff = calculateStringDiff("hello world", "hello moon");
expect(applyStringDiff("hello world", diff)).toBe("hello moon");
});
test("common suffix is captured", () => {
const diff = calculateStringDiff("testing", "besting");
expect(diff.suffixLen).toBeGreaterThan(0);
expect(applyStringDiff("testing", diff)).toBe("besting");
});
test("diff applies correctly", () => {
const diff = calculateStringDiff("hello world", "hello there");
expect(applyStringDiff("hello world", diff)).toBe("hello there");
});
test("shouldUseDiff returns true for similar strings", () => {
expect(shouldUseDiff("hello world", "hello there")).toBe(true);
});
test("calculateDiffSavings returns percentage", () => {
const diff = calculateStringDiff("hello world", "hello there");
const savings = calculateDiffSavings(
"hello world",
"hello there",
diff
);
expect(savings).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,8 @@
## index
**Path:** `bitpacker-delta/src/index.ts`
**Export:** See source for details.
### What it does
Part of the @suaveplan/ package.

View File

@@ -0,0 +1,26 @@
/**
* Delta encoding for efficient repeated state transmission.
*/
export {
type DeltaConfig,
DeltaManager,
type DeltaManagerOptions,
type FieldChange,
} from "./delta-manager/delta-manager.js";
export { DeltaStateManager } from "./delta-state/delta-state.js";
export {
applyStringDiff,
calculateDiffSavings,
calculateStringDiff,
type StringDiff,
shouldUseDiff,
} from "./string-diff.js";
export {
applyXorDelta,
calculateDeltaBitSize,
calculateXorDelta,
isXorEfficient,
numberXor,
} from "./xor-encoder.js";

View File

@@ -0,0 +1,26 @@
## String Diff
**Path:** `src/string-diff.ts`
**Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUseDiff`, `StringDiff`
### What it does
Efficiently encodes string changes by finding common prefix and suffix, only encoding the differing middle section.
### Key API
| Function | Description |
|----------|-------------|
| `calculateStringDiff(previous, current)` | Compute `{ prefixLen, suffixLen, middleLen, middle }` |
| `applyStringDiff(previous, diff)` | Reconstruct current string from diff |
| `calculateDiffSavings(previous, current, diff)` | Percentage savings from diff encoding |
| `shouldUseDiff(previous, current, threshold?)` | Decide whether diff encoding is worthwhile |
### Format
`[prefixLen][suffixLen][middleLen][middleChars...]` — each length field is 1 byte.
### Used by
- Delta compression for string fields in game state
- Network protocols with repetitive string data

View File

@@ -0,0 +1,113 @@
/**
* String Diff Encoding for Delta Compression
*
* Efficiently encodes string changes by:
* - Common prefix elimination
* - Common suffix elimination
* - Only encoding the differing middle section
*
* Format: [prefixLen][suffixLen][middleLength][middleChars...]
*/
export interface StringDiff {
/** Length of common prefix */
prefixLen: number;
/** Length of common suffix */
suffixLen: number;
/** Length of middle (differing) section */
middleLen: number;
/** Middle (differing) string section */
middle: string;
}
/**
* Calculate diff between two strings.
*/
export function calculateStringDiff(
previous: string,
current: string
): StringDiff {
if (previous === current) {
return {
prefixLen: previous.length,
suffixLen: 0,
middleLen: 0,
middle: "",
};
}
if (previous.length === 0) {
return {
prefixLen: 0,
suffixLen: 0,
middleLen: current.length,
middle: current,
};
}
if (current.length === 0) {
return { prefixLen: 0, suffixLen: 0, middleLen: 0, middle: "" };
}
let prefixLen = 0;
const minLen = Math.min(previous.length, current.length);
while (prefixLen < minLen && previous[prefixLen] === current[prefixLen]) {
prefixLen++;
}
let suffixLen = 0;
let prevEnd = previous.length - 1;
let currEnd = current.length - 1;
while (
suffixLen < minLen - prefixLen &&
prevEnd >= prefixLen &&
currEnd >= prefixLen &&
previous[prevEnd] === current[currEnd]
) {
suffixLen++;
prevEnd--;
currEnd--;
}
const middleStart = prefixLen;
const middleEnd = current.length - suffixLen;
const middle = current.substring(middleStart, middleEnd);
return { prefixLen, suffixLen, middleLen: middle.length, middle };
}
/**
* Apply diff to reconstruct current string from previous.
*/
export function applyStringDiff(previous: string, diff: StringDiff): string {
const prefix = previous.substring(0, diff.prefixLen);
const suffix = previous.substring(previous.length - diff.suffixLen);
return prefix + diff.middle + suffix;
}
/**
* Calculate savings percentage from using diff encoding vs full string.
*/
export function calculateDiffSavings(
_previous: string,
current: string,
diff: StringDiff
): number {
const fullSize = 1 + current.length;
const diffSize = 3 + diff.middleLen;
return ((fullSize - diffSize) / fullSize) * 100;
}
/**
* Determine if diff encoding should be used based on savings threshold.
*/
export function shouldUseDiff(
previous: string,
current: string,
threshold = 0
): boolean {
const diff = calculateStringDiff(previous, current);
return calculateDiffSavings(previous, current, diff) >= threshold;
}

View File

@@ -0,0 +1,23 @@
## XOR Encoder
**Path:** `src/xor-encoder.ts`
**Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficient`, `numberXor`
### What it does
XOR-based delta encoding for numeric values. Encodes the difference between values rather than absolute values, which can save space when consecutive values are similar.
### Key API
| Function | Description |
|----------|-------------|
| `numberXor(a, b)` | XOR two float64 values byte-by-byte |
| `calculateXorDelta(current, previous)` | Compute `current ^ previous` |
| `applyXorDelta(previous, delta)` | Reconstruct `current` from `previous ^ delta` |
| `calculateDeltaBitSize(delta)` | Count significant bits in delta |
| `isXorEfficient(current, previous, maxBits)` | Determine if XOR saves space |
### Used by
- `@suaveplan/bitpacker-delta` `DeltaManager` for change detection
- Downstream compression pipelines

View File

@@ -0,0 +1,60 @@
/**
* XOR-based delta encoding for numeric values.
* Encodes the difference between values rather than absolute values.
*/
/**
* XOR two float64 values byte-by-byte.
*/
export function numberXor(a: number, b: number): number {
const aBuf = new ArrayBuffer(8);
const bBuf = new ArrayBuffer(8);
new DataView(aBuf).setFloat64(0, a, false);
new DataView(bBuf).setFloat64(0, b, false);
const aBytes = new Uint8Array(aBuf);
const bBytes = new Uint8Array(bBuf);
const result = new Uint8Array(8);
for (let i = 0; i < 8; i++) {
result[i] = aBytes[i]! ^ bBytes[i]!;
}
return new DataView(result.buffer).getFloat64(0, false);
}
/**
* Calculate XOR delta between two integers.
*/
export function calculateXorDelta(current: number, previous: number): number {
return (current ^ previous) >>> 0;
}
/**
* Reconstruct current value from previous and XOR delta.
*/
export function applyXorDelta(previous: number, delta: number): number {
return (previous ^ delta) >>> 0;
}
/**
* Calculate the number of significant bits in a delta value.
*/
export function calculateDeltaBitSize(delta: number): number {
if (delta === 0) return 1;
return Math.floor(Math.log2(delta)) + 1;
}
/**
* Determine if XOR delta encoding would save space.
*/
export function isXorEfficient(
current: number,
previous: number,
maxBits: number
): boolean {
const deltaBits = calculateDeltaBitSize(
calculateXorDelta(current, previous)
);
return deltaBits < maxBits;
}

View File

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

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/bitpacker",
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: [
"tslib",
"zod",
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/types",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "istanbul",
},
},
});