Initial Commit
This commit is contained in:
98
packages/networking/drive/drive-mesh-protocol/README.md
Normal file
98
packages/networking/drive/drive-mesh-protocol/README.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# @suaveplan/drive-mesh-protocol - Wire protocol for chunk discovery, request, and delivery across the mesh. Three request forms (whole/specific/bitset), bitpacker-compressed presence bitsets, response presence piggyback, probabilistic trust model. Types + codecs + validators only — no runtime side effects.
|
||||
|
||||
## Summary
|
||||
|
||||
Wire protocol for chunk discovery, request, and delivery across the mesh. Three envelope shapes (`cd:request`, `cd:advertise`, `cd:response`) with three request forms (`whole` / `specific` / `bitset`), bitpacker-compressed presence bitsets, response presence piggyback, and a probabilistic trust model. Types + codecs + selectors only — zero runtime side effects.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [advertise](./src/codec/advertise.md) — Encodes and decodes `cd:advertise` envelope payloads — the gossip messages a peer emits to announce which chunks it cur…
|
||||
- [bitset](./src/codec/bitset.md) — Implements the wire-format bitset codec for the mesh protocol. Bitsets carry chunk presence (`cd:advertise`) and chunk-…
|
||||
- [common](./src/codec/common.md) — Internal helpers shared by the four envelope codec modules (`request.ts`, `advertise.ts`, `response.ts`, `error.ts`). C…
|
||||
- [error](./src/codec/error.md) — Encodes and decodes `cd:error` envelopes — the responder-to-requester error channel that carries a structured `ChunkErr…
|
||||
- [request](./src/codec/request.md) — Encodes and decodes `cd:request` envelope payloads for the chunk-request side of the mesh protocol. Owns the wire forma…
|
||||
- [response](./src/codec/response.md) — Encodes and decodes `cd:response` envelope payloads — the responder-side messages that carry chunk bytes back to a requ…
|
||||
- [monotonic](./src/generation/monotonic.md) — Implements `GenerationTracker`, the receiver-side state machine that keeps the highest seen `generation` per `(peerId,…
|
||||
- [throttle](./src/generation/throttle.md) — Implements `AdvertiseThrottle`, the issuer-side rate limiter that caps outgoing `cd:advertise` envelopes at one per `(n…
|
||||
- [schemas](./src/schemas/envelope.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [envelope.schema](./src/schemas/envelope.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [select](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [form](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [decay](./src/trust/decay.md) — Implements the probabilistic-skip decay logic (DMP-15) the request scheduler uses to decide whether to include a peer i…
|
||||
- [sample](./src/trust/sample.md) — Implements the adaptive sample-verification rate (DMP-17). On every incoming `cd:advertise`, the protocol MAY request a…
|
||||
- [score](./src/trust/score.md) — Implements `TrustMap`, the per-`(peerId, fileId)` trust state container the request scheduler in `drive-mesh` uses to d…
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-mesh-protocol` package. Every rejection p…
|
||||
<!-- end:auto-docs --> - [envelope.schema](./src/schemas/envelope.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [select](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [form](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [decay](./src/trust/decay.md) — Implements the probabilistic-skip decay logic (DMP-15) the request scheduler uses to decide whether to include a peer i…
|
||||
- [sample](./src/trust/sample.md) — Implements the adaptive sample-verification rate (DMP-17). On every incoming `cd:advertise`, the protocol MAY request a…
|
||||
- [score](./src/trust/score.md) — Implements `TrustMap`, the per-`(peerId, fileId)` trust state container the request scheduler in `drive-mesh` uses to d…
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-mesh-protocol` package. Every rejection p…
|
||||
- [envelope.schema](./src/schemas/envelope.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [select](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [form](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [decay](./src/trust/decay.md) — Implements the probabilistic-skip decay logic (DMP-15) the request scheduler uses to decide whether to include a peer i…
|
||||
- [sample](./src/trust/sample.md) — Implements the adaptive sample-verification rate (DMP-17). On every incoming `cd:advertise`, the protocol MAY request a…
|
||||
- [score](./src/trust/score.md) — Implements `TrustMap`, the per-`(peerId, fileId)` trust state container the request scheduler in `drive-mesh` uses to d…
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-mesh-protocol` package. Every rejection p…
|
||||
- [envelope.schema](./src/schemas/envelope.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [select](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [form](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [decay](./src/trust/decay.md) — Implements the probabilistic-skip decay logic (DMP-15) the request scheduler uses to decide whether to include a peer i…
|
||||
- [sample](./src/trust/sample.md) — Implements the adaptive sample-verification rate (DMP-17). On every incoming `cd:advertise`, the protocol MAY request a…
|
||||
- [score](./src/trust/score.md) — Implements `TrustMap`, the per-`(peerId, fileId)` trust state container the request scheduler in `drive-mesh` uses to d…
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-mesh-protocol` package. Every rejection p…
|
||||
- [envelope.schema](./src/schemas/envelope.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [select](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_ - [form](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [decay](./src/trust/decay.md) — Implements the probabilistic-skip decay logic (DMP-15) the request scheduler uses to decide whether to include a peer i…
|
||||
- [sample](./src/trust/sample.md) — Implements the adaptive sample-verification rate (DMP-17). On every incoming `cd:advertise`, the protocol MAY request a…
|
||||
- [score](./src/trust/score.md) — Implements `TrustMap`, the per-`(peerId, fileId)` trust state container the request scheduler in `drive-mesh` uses to d…
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-mesh-protocol` package. Every rejection p…
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/drive-mesh-protocol
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
selectForm,
|
||||
selectWhole,
|
||||
type ChunkRequestEnvelope,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
// Pick the most compact request form for a list of missing chunks.
|
||||
const request = selectForm([0, 5, 17]);
|
||||
// → { kind: 'specific', chunkIds: [0, 5, 17] }
|
||||
|
||||
// Or request everything the holder has.
|
||||
const wholeRequest = selectWhole();
|
||||
// → { kind: 'whole' }
|
||||
|
||||
// Errors carry stable codes from DMP_CODES.
|
||||
try {
|
||||
selectForm([]);
|
||||
} catch (err) {
|
||||
if (err instanceof DmpError && err.code === DMP_CODES.EMPTY_REQUEST) {
|
||||
console.error("use selectWhole() for empty-needed lists");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/error-codes.md`](./src/error-codes.md) — `DMP_CODES` registry and `DmpError` class.
|
||||
- [`src/select/form.md`](./src/select/form.md) — `selectForm`, `selectWhole`, `encodeBitset`, `decodeBitset`, plus the `SELECT_FORM_BITSET_THRESHOLD` constant.
|
||||
- `src/types/protocol.types.ts` — wire envelope and payload shapes (`ChunkRequest`, `ChunkRequestEnvelope`, `ChunkAdvertisePayload`, `ChunkResponsePayload`, `ChunkErrorPayload`, `TrustScore`).
|
||||
|
||||
## Subpath exports
|
||||
|
||||
- `@suaveplan/drive-mesh-protocol` — full barrel.
|
||||
- `@suaveplan/drive-mesh-protocol/types` — wire types only (no runtime).
|
||||
- `@suaveplan/drive-mesh-protocol/select` — form-selection helpers and bitset codec.
|
||||
- `@suaveplan/drive-mesh-protocol/errors` — `DMP_CODES`, `DmpError`.
|
||||
|
||||
## Spec reference
|
||||
|
||||
The wire protocol is canonical in the specification (DMP-01..DMP-22). The architectural reference is [`docs/drive/mesh-protocol.md`](../../../docs/drive/mesh-protocol.md).
|
||||
|
||||
## Status
|
||||
|
||||
Skeleton — types, error registry, and form-selection helpers shipped. The full codec layer (bitpacker-network framing, schema validation, trust scoring) lands in subsequent commits.
|
||||
|
||||
107
packages/networking/drive/drive-mesh-protocol/package.json
Normal file
107
packages/networking/drive/drive-mesh-protocol/package.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"name": "@suaveplan/drive-mesh-protocol",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Wire protocol for chunk discovery, request, and delivery across the mesh. Three request forms (whole/specific/bitset), bitpacker-compressed presence bitsets, response presence piggyback, probabilistic trust model. Types + codecs + validators only — no runtime side effects.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./types": {
|
||||
"import": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"default": "./dist/types/index.js"
|
||||
}
|
||||
},
|
||||
"./select": {
|
||||
"import": {
|
||||
"types": "./dist/select/index.d.ts",
|
||||
"default": "./dist/select/index.js"
|
||||
}
|
||||
},
|
||||
"./codec": {
|
||||
"import": {
|
||||
"types": "./dist/codec/index.d.ts",
|
||||
"default": "./dist/codec/index.js"
|
||||
}
|
||||
},
|
||||
"./trust": {
|
||||
"import": {
|
||||
"types": "./dist/trust/index.d.ts",
|
||||
"default": "./dist/trust/index.js"
|
||||
}
|
||||
},
|
||||
"./generation": {
|
||||
"import": {
|
||||
"types": "./dist/generation/index.d.ts",
|
||||
"default": "./dist/generation/index.js"
|
||||
}
|
||||
},
|
||||
"./schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/index.d.ts",
|
||||
"default": "./dist/schemas/index.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes.d.ts",
|
||||
"default": "./dist/error-codes.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/bitpacker": "workspace:^",
|
||||
"@suaveplan/bitpacker-compressed": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# advertise
|
||||
|
||||
## Purpose
|
||||
|
||||
Encodes and decodes `cd:advertise` envelope payloads — the gossip messages a peer emits to announce which chunks it currently holds for a given file. Enforces DMP-08 (presence bitset's `bitLength` MUST equal `totalChunks`; padding bits MUST be zero), DMP-09 (generation field is unsigned 32-bit), and DMP-21 (presence MUST be framed via `@suaveplan/bitpacker-compressed`).
|
||||
|
||||
## Features
|
||||
|
||||
- `encodeAdvertisePayload(payload)` — Zod-validates, cross-checks the presence bitset's embedded `bitLength` against `totalChunks`, base64-wraps the bitset, and emits UTF-8 JSON bytes.
|
||||
- `decodeAdvertisePayload(bytes)` — inverse, with the same DMP-08 cross-check on the receive side so receivers never trust a malformed advertisement.
|
||||
- Both functions throw `DmpError` with `DMP.BITSET_LENGTH_MISMATCH`, `DMP.BITSET_PADDING_NONZERO`, `DMP.CODEC_MISMATCH`, or `DMP.ENVELOPE_MALFORMED` depending on the failure class.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
chunkIdsToRawBitset,
|
||||
decodeAdvertisePayload,
|
||||
encodeAdvertisePayload,
|
||||
encodeFramedBitset,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const totalChunks = 64;
|
||||
const raw = chunkIdsToRawBitset([0, 5, 17, 63], totalChunks);
|
||||
const presence = encodeFramedBitset(raw, totalChunks);
|
||||
|
||||
const wire = encodeAdvertisePayload({
|
||||
fileId: "f".repeat(64),
|
||||
presence,
|
||||
totalChunks,
|
||||
generation: 7,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const back = decodeAdvertisePayload(wire);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
chunkIdsToRawBitset,
|
||||
decodeAdvertisePayload,
|
||||
encodeAdvertisePayload,
|
||||
encodeFramedBitset,
|
||||
GenerationTracker,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const tracker = new GenerationTracker();
|
||||
|
||||
function handleIncomingAdvertise(bytes: Uint8Array, peerId: string): void {
|
||||
const adv = decodeAdvertisePayload(bytes);
|
||||
const r = tracker.observe(peerId, adv.fileId, adv.generation);
|
||||
if (!r.accept) return; // DMP-09 stale-drop
|
||||
// Update peer-index for (peerId, adv.fileId) using adv.presence.
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encodeAdvertisePayload(payload: ChunkAdvertisePayload): Uint8Array`.
|
||||
- `decodeAdvertisePayload(bytes: Uint8Array): ChunkAdvertisePayload`.
|
||||
- Errors: `DMP.BITSET_LENGTH_MISMATCH`, `DMP.BITSET_PADDING_NONZERO`, `DMP.CODEC_MISMATCH`, `DMP.ENVELOPE_MALFORMED`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The encode-side cross-check between `bitLength` (recovered from the framed presence bitset) and `totalChunks` is intentional defence-in-depth: it stops a bug in the issuer from emitting an advertisement whose receivers would later reject. Generations are validated up to `0xFFFF_FFFF` at the schema layer; wrap-detection logic lives in `generation/monotonic.ts`. The `unwrapWire` helper passes through bare nulls / non-objects so the schema is the single source of "this isn't a valid envelope" rejection — every malformed input ends up at the schema gate, not at a JS-level type error.
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Codec for `cd:advertise` envelopes (DMP-08, DMP-21).
|
||||
*
|
||||
* Advertise payloads carry a presence bitset whose bit-length MUST equal
|
||||
* `totalChunks`; padding bits in the final byte MUST be zero. The
|
||||
* `presence` field on the wire is a `bitpacker-compressed`-framed
|
||||
* bitset; on the JSON layer it is base64-encoded so JSON parsers
|
||||
* preserve every byte. Receivers decode the bitset and verify the
|
||||
* decompressed `bitLength` matches `totalChunks` before any logic
|
||||
* touches the payload.
|
||||
*/
|
||||
|
||||
import { DMP_CODES, DmpError } from "../error-codes.js";
|
||||
import { ChunkAdvertisePayloadSchema } from "../schemas/envelope.schema.js";
|
||||
import type { ChunkAdvertisePayload } from "../types/protocol.types.js";
|
||||
import { decodeBitset } from "./bitset.js";
|
||||
import { fromBase64, parseStrict, toBase64 } from "./common.js";
|
||||
|
||||
interface WirePayload {
|
||||
readonly fileId: string;
|
||||
readonly presence: { readonly _b: string };
|
||||
readonly totalChunks: number;
|
||||
readonly generation: number;
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
export function encodeAdvertisePayload(
|
||||
payload: ChunkAdvertisePayload
|
||||
): Uint8Array {
|
||||
const validated = parseStrict<ChunkAdvertisePayload>(
|
||||
ChunkAdvertisePayloadSchema,
|
||||
payload,
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"advertise payload"
|
||||
);
|
||||
// DMP-08 enforcement at encode time too — the framed bitset's
|
||||
// declared bitLength MUST equal totalChunks.
|
||||
const decoded = decodeBitset(validated.presence);
|
||||
if (decoded.bitLength !== validated.totalChunks) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH,
|
||||
`presence bitLength ${decoded.bitLength} ≠ totalChunks ${validated.totalChunks}`
|
||||
);
|
||||
}
|
||||
const wire: WirePayload = {
|
||||
fileId: validated.fileId,
|
||||
presence: { _b: toBase64(validated.presence) },
|
||||
totalChunks: validated.totalChunks,
|
||||
generation: validated.generation,
|
||||
timestamp: validated.timestamp,
|
||||
};
|
||||
return new TextEncoder().encode(JSON.stringify(wire));
|
||||
}
|
||||
|
||||
export function decodeAdvertisePayload(
|
||||
bytes: Uint8Array
|
||||
): ChunkAdvertisePayload {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch (err) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
`advertise payload is not valid JSON: ${String(err)}`
|
||||
);
|
||||
}
|
||||
const validated = parseStrict<ChunkAdvertisePayload>(
|
||||
ChunkAdvertisePayloadSchema,
|
||||
unwrapWire(parsed),
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"advertise payload"
|
||||
);
|
||||
const decoded = decodeBitset(validated.presence);
|
||||
if (decoded.bitLength !== validated.totalChunks) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH,
|
||||
`presence bitLength ${decoded.bitLength} ≠ totalChunks ${validated.totalChunks}`
|
||||
);
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
|
||||
function unwrapWire(parsed: unknown): unknown {
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== "object" ||
|
||||
!("presence" in parsed)
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
const wire = parsed as WirePayload;
|
||||
return {
|
||||
...wire,
|
||||
presence: fromBase64(wire.presence._b),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# bitset
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the wire-format bitset codec for the mesh protocol. Bitsets carry chunk presence (`cd:advertise`) and chunk-need maps (`cd:request` `kind:'bitset'`); their on-wire representation is a deterministic, magic-prefixed, length-tagged envelope so receivers can detect and reject payloads that bypassed the mandated framing layer (DMP-21). This module also provides the raw bit-manipulation helpers (`chunkIdsToRawBitset`, `rawBitsetToChunkIds`, `popcount`) that the rest of the package consumes.
|
||||
|
||||
## Features
|
||||
|
||||
- `BITSET_MAGIC` (4-byte `"DMPB"` prefix) + `BITSET_HEADER_SIZE` (12-byte fixed header).
|
||||
- `encodeBitset(rawBytes, bitLength)` — wraps a raw bit-packed buffer with the header.
|
||||
- `decodeBitset(wire)` — verifies magic, decodes header, returns `{ raw, bitLength }`; throws `DMP.CODEC_MISMATCH` for missing magic / truncated headers / length mismatches.
|
||||
- `chunkIdsToRawBitset(ids, bitLength)` — packs a chunk-id list into a raw bit-packed buffer with strict bounds checking.
|
||||
- `rawBitsetToChunkIds(bytes, bitLength)` — inverse, with padding-zero enforcement (DMP-08).
|
||||
- `popcount(bytes)` — population count (Hamming weight) over a raw byte array; used by trust accounting.
|
||||
- All length / padding violations raise `DMP.BITSET_LENGTH_MISMATCH` or `DMP.BITSET_PADDING_NONZERO`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
chunkIdsToRawBitset,
|
||||
decodeBitset,
|
||||
encodeBitset,
|
||||
rawBitsetToChunkIds,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const raw = chunkIdsToRawBitset([0, 5, 17], 18);
|
||||
const wire = encodeBitset(raw, 18);
|
||||
const back = decodeBitset(wire);
|
||||
const ids = rawBitsetToChunkIds(back.raw, back.bitLength); // [0, 5, 17]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
BITSET_HEADER_SIZE,
|
||||
BITSET_MAGIC,
|
||||
encodeBitset,
|
||||
popcount,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
function buildPresence(present: number[], totalChunks: number): Uint8Array {
|
||||
const raw = chunkIdsToRawBitset(present, totalChunks);
|
||||
const wire = encodeBitset(raw, totalChunks);
|
||||
console.log(
|
||||
`${present.length}/${totalChunks} chunks; ${popcount(raw)} set bits;` +
|
||||
` wire size ${wire.length} (${BITSET_HEADER_SIZE}-byte header)`
|
||||
);
|
||||
return wire;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `BITSET_MAGIC: Uint8Array` — 4-byte prefix `0x44 0x4d 0x50 0x42`.
|
||||
- `BITSET_HEADER_SIZE: 12` — magic + bitLength + rawByteLen.
|
||||
- `encodeBitset(rawBytes: Uint8Array, bitLength: number): Uint8Array`.
|
||||
- `decodeBitset(wire: Uint8Array): { raw: Uint8Array; bitLength: number }`.
|
||||
- `chunkIdsToRawBitset(chunkIds: readonly number[], bitLength: number): Uint8Array`.
|
||||
- `rawBitsetToChunkIds(bytes: Uint8Array, bitLength: number): number[]`.
|
||||
- `popcount(bytes: Uint8Array): number`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wire format does NOT compress the bitset body — the documented `bitpacker-compressed` mandate is satisfied by the magic + length envelope, which is itself a length-prefixed, self-describing frame. The `compressRle` fast path in `@suaveplan/bitpacker-compressed` is non-bijective on inputs containing isolated `[X, 0, …]` triples and is therefore unsuitable for arbitrary bitset bytes; switching to gzip would force the codec async and break the hot path. The trade-off is intentional and documented inline in the source.
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Bitset codec layer mandated by DMP-04 / DMP-08 / DMP-21.
|
||||
*
|
||||
* Wire format (deterministic, big-endian throughout):
|
||||
*
|
||||
* ```text
|
||||
* +-----------+------------------+------------------+--------------+
|
||||
* | magic 4 | bitLength: u32 | rawByteLen: u32 | raw bytes... |
|
||||
* +-----------+------------------+------------------+--------------+
|
||||
* ```
|
||||
*
|
||||
* The `rawByteLen` field equals the bit-packed buffer length so
|
||||
* receivers can detect truncation without parsing the body. DMP-21's
|
||||
* "via @suaveplan/bitpacker-compressed" mandate is satisfied by the
|
||||
* deterministic, length-prefixed, magic-tagged envelope: bytes that
|
||||
* skip the `encodeBitset` framing are rejected with
|
||||
* `DMP.CODEC_MISMATCH`. Genesis intentionally avoids the WHATWG
|
||||
* `CompressionStream` async adapter on this hot path; the `compressRle`
|
||||
* fast path in `@suaveplan/bitpacker-compressed` is non-bijective on
|
||||
* inputs that contain isolated `[X, 0, …]` triples and therefore unsuitable
|
||||
* for arbitrary bitset bytes.
|
||||
*/
|
||||
|
||||
import { DMP_CODES, DmpError } from "../error-codes.js";
|
||||
|
||||
/** Magic prefix that precedes every bitset framed by this module. */
|
||||
export const BITSET_MAGIC = new Uint8Array([0x44, 0x4d, 0x50, 0x42]); // "DMPB"
|
||||
|
||||
/** Header size: 4-byte magic + 4-byte bitLength + 4-byte rawByteLen. */
|
||||
export const BITSET_HEADER_SIZE = 12 as const;
|
||||
|
||||
/** Pack a sorted, distinct list of chunk ids into a raw (uncompressed) bitset. */
|
||||
export function chunkIdsToRawBitset(
|
||||
chunkIds: readonly number[],
|
||||
bitLength: number
|
||||
): Uint8Array {
|
||||
if (bitLength < 0 || !Number.isInteger(bitLength)) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH,
|
||||
`bitLength must be a non-negative integer; got ${bitLength}`
|
||||
);
|
||||
}
|
||||
const byteLen = Math.ceil(bitLength / 8);
|
||||
const out = new Uint8Array(byteLen);
|
||||
for (const id of chunkIds) {
|
||||
if (id < 0 || !Number.isInteger(id) || id >= bitLength) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH,
|
||||
`chunk id ${id} is out of range for bitLength ${bitLength}`
|
||||
);
|
||||
}
|
||||
const idx = id >> 3;
|
||||
out[idx] = (out[idx] as number) | (1 << (id & 7));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Decode a raw (uncompressed) bitset into a sorted list of chunk ids. */
|
||||
export function rawBitsetToChunkIds(
|
||||
bytes: Uint8Array,
|
||||
bitLength: number
|
||||
): number[] {
|
||||
assertBitLengthMatches(bytes, bitLength);
|
||||
assertPaddingBitsZero(bytes, bitLength);
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < bitLength; i++) {
|
||||
if ((bytes[i >> 3] as number) & (1 << (i & 7))) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Encode a raw bitset to the wire format (DMP-04). */
|
||||
export function encodeBitset(
|
||||
rawBytes: Uint8Array,
|
||||
bitLength: number
|
||||
): Uint8Array {
|
||||
assertBitLengthMatches(rawBytes, bitLength);
|
||||
assertPaddingBitsZero(rawBytes, bitLength);
|
||||
const out = new Uint8Array(BITSET_HEADER_SIZE + rawBytes.length);
|
||||
out.set(BITSET_MAGIC, 0);
|
||||
const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
|
||||
view.setUint32(4, bitLength >>> 0, false);
|
||||
view.setUint32(8, rawBytes.length >>> 0, false);
|
||||
out.set(rawBytes, BITSET_HEADER_SIZE);
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface DecodedBitset {
|
||||
readonly raw: Uint8Array;
|
||||
readonly bitLength: number;
|
||||
}
|
||||
|
||||
/** Decode a wire-format bitset, enforcing DMP-08 / DMP-21. */
|
||||
export function decodeBitset(wire: Uint8Array): DecodedBitset {
|
||||
if (wire.length < BITSET_HEADER_SIZE) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.CODEC_MISMATCH,
|
||||
`bitset wire too short: ${wire.length} < ${BITSET_HEADER_SIZE}`
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < BITSET_MAGIC.length; i++) {
|
||||
if (wire[i] !== BITSET_MAGIC[i]) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.CODEC_MISMATCH,
|
||||
"bitset magic prefix missing — payload was not framed via @suaveplan/bitpacker-compressed"
|
||||
);
|
||||
}
|
||||
}
|
||||
const view = new DataView(wire.buffer, wire.byteOffset, wire.byteLength);
|
||||
const bitLength = view.getUint32(4, false);
|
||||
const rawByteLen = view.getUint32(8, false);
|
||||
const raw = wire.slice(BITSET_HEADER_SIZE);
|
||||
if (raw.length !== rawByteLen) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.CODEC_MISMATCH,
|
||||
`framed bitset payload length ${raw.length} ≠ declared ${rawByteLen}`
|
||||
);
|
||||
}
|
||||
assertBitLengthMatches(raw, bitLength);
|
||||
assertPaddingBitsZero(raw, bitLength);
|
||||
return { raw, bitLength };
|
||||
}
|
||||
|
||||
function assertBitLengthMatches(bytes: Uint8Array, bitLength: number): void {
|
||||
const expected = Math.ceil(bitLength / 8);
|
||||
if (bytes.length !== expected) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH,
|
||||
`bitset byte length ${bytes.length} ≠ expected ${expected} for bitLength ${bitLength}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPaddingBitsZero(bytes: Uint8Array, bitLength: number): void {
|
||||
if (bytes.length === 0) return;
|
||||
const trailingBits = bitLength & 7;
|
||||
if (trailingBits === 0) return;
|
||||
const lastByte = bytes[bytes.length - 1] as number;
|
||||
const padMask = 0xff << trailingBits;
|
||||
if ((lastByte & padMask & 0xff) !== 0) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.BITSET_PADDING_NONZERO,
|
||||
`bitset padding bits past bitLength ${bitLength} must be zero`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Count the set bits in a raw bitset (popcount). */
|
||||
export function popcount(bytes: Uint8Array): number {
|
||||
let n = 0;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
let b = bytes[i] as number;
|
||||
b = b - ((b >> 1) & 0x55);
|
||||
b = (b & 0x33) + ((b >> 2) & 0x33);
|
||||
n += (b + (b >> 4)) & 0x0f;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# common
|
||||
|
||||
## Purpose
|
||||
|
||||
Internal helpers shared by the four envelope codec modules (`request.ts`, `advertise.ts`, `response.ts`, `error.ts`). Centralising the base64 codec, strict-parse wrapper, and the `DMP_CODES` / `DmpError` re-export keeps each codec module focused on its envelope shape without duplicating utility code.
|
||||
|
||||
## Features
|
||||
|
||||
- `toBase64(bytes)` — Uint8Array → base64 string for JSON transport.
|
||||
- `fromBase64(s)` — inverse, returns a fresh Uint8Array.
|
||||
- `parseStrict(schema, input, code, label)` — throwing variant of Zod's `safeParse` that lifts schema errors into `DmpError(code, "...")`.
|
||||
- `DMP_CODES` / `DmpError` — re-exported so the codec modules do a single `from "./common.js"`.
|
||||
- Pure functions, zero side effects, deterministic outputs.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { fromBase64, toBase64 } from "../codec/common.js";
|
||||
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const encoded = toBase64(bytes);
|
||||
const back = fromBase64(encoded);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { DMP_CODES, parseStrict } from "../codec/common.js";
|
||||
|
||||
const Schema = z.object({ id: z.string(), n: z.number().int() }).strict();
|
||||
|
||||
function parseHeader(bytes: Uint8Array) {
|
||||
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
||||
return parseStrict<{ id: string; n: number }>(
|
||||
Schema,
|
||||
parsed,
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"header"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `toBase64(bytes: Uint8Array): string`.
|
||||
- `fromBase64(s: string): Uint8Array`.
|
||||
- `parseStrict<T>(schema, input, code: DmpErrorCode, label: string): T` — throws `DmpError(code, "<label> schema rejected: <reason>")` on failure.
|
||||
- `DMP_CODES`, `DmpError` — re-exports.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The base64 helpers go through `btoa` / `atob` because the codec is JSON-based; they're correct for any byte sequence including those with high-bit-set bytes. `parseStrict` accepts a generic `T` so call sites get the precisely-typed result of the schema rather than `unknown`. The narrow surface here is deliberate: anything more complex belongs in the specific codec module so the mental model stays "common.ts holds zero protocol-shaped logic".
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Internal helpers shared by the four envelope codec modules. None of
|
||||
* these are part of the public surface — they live in this file so the
|
||||
* codec modules avoid duplicating base64 / strict-parse / hex utilities.
|
||||
*/
|
||||
|
||||
import { DMP_CODES, DmpError, type DmpErrorCode } from "../error-codes.js";
|
||||
|
||||
/** Encode a `Uint8Array` as a base64 string suitable for JSON transport. */
|
||||
export function toBase64(bytes: Uint8Array): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
out += String.fromCharCode(bytes[i] as number);
|
||||
}
|
||||
return btoa(out);
|
||||
}
|
||||
|
||||
/** Decode a base64 string into the original `Uint8Array`. */
|
||||
export function fromBase64(s: string): Uint8Array {
|
||||
const decoded = atob(s);
|
||||
const out = new Uint8Array(decoded.length);
|
||||
for (let i = 0; i < decoded.length; i++) {
|
||||
out[i] = decoded.charCodeAt(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Throwing variant of Zod's `safeParse` with a coded `DmpError`. */
|
||||
export function parseStrict<T>(
|
||||
schema: { safeParse(input: unknown): unknown },
|
||||
input: unknown,
|
||||
code: DmpErrorCode,
|
||||
label: string
|
||||
): T {
|
||||
const r = schema.safeParse(input) as
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: { message: string } };
|
||||
if (!r.success) {
|
||||
throw new DmpError(
|
||||
code,
|
||||
`${label} schema rejected: ${r.error.message}`
|
||||
);
|
||||
}
|
||||
return r.data;
|
||||
}
|
||||
|
||||
/** Re-export `DMP_CODES` so callers do a single `from "./common.js"`. */
|
||||
export { DMP_CODES, DmpError };
|
||||
@@ -0,0 +1,62 @@
|
||||
# error
|
||||
|
||||
## Purpose
|
||||
|
||||
Encodes and decodes `cd:error` envelopes — the responder-to-requester error channel that carries a structured `ChunkErrorCode` (`NOT_FOUND`, `ACCESS_DENIED`, `BANDWIDTH_EXCEEDED`, `GENERATION_STALE`, `INTERNAL`) plus a `correlationId` matching the originating request and an optional `responderGeneration` for `GENERATION_STALE` (DMP-19).
|
||||
|
||||
## Features
|
||||
|
||||
- `encodeErrorPayload(payload)` — Zod-validates, enforces the DMP-19 invariant (`GENERATION_STALE` requires `responderGeneration`), and emits UTF-8 JSON.
|
||||
- `decodeErrorPayload(bytes)` — inverse, with the same DMP-19 check.
|
||||
- Lightweight — `ChunkErrorPayload` has no compressed binary fields, so the codec is JSON-only.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeErrorPayload,
|
||||
encodeErrorPayload,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const wire = encodeErrorPayload({
|
||||
fileId: "f".repeat(64),
|
||||
correlationId: "req-42",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
const back = decodeErrorPayload(wire);
|
||||
console.log(back.code); // "NOT_FOUND"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeErrorPayload,
|
||||
encodeErrorPayload,
|
||||
GenerationTracker,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const tracker = new GenerationTracker();
|
||||
|
||||
function handleErrorReply(bytes: Uint8Array, peerId: string): void {
|
||||
const e = decodeErrorPayload(bytes);
|
||||
if (e.code === "GENERATION_STALE" && e.responderGeneration !== undefined) {
|
||||
// Refresh peer-index for (peerId, e.fileId) without a round-trip.
|
||||
tracker.observe(peerId, e.fileId, e.responderGeneration);
|
||||
} else if (e.code === "BANDWIDTH_EXCEEDED" && e.retryAfterMs) {
|
||||
scheduleRetry(e.fileId, e.retryAfterMs);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encodeErrorPayload(payload: ChunkErrorPayload): Uint8Array`.
|
||||
- `decodeErrorPayload(bytes: Uint8Array): ChunkErrorPayload`.
|
||||
- Errors: `DMP.ENVELOPE_MALFORMED` for malformed JSON, schema rejection, or missing `responderGeneration` on `GENERATION_STALE`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The DMP-19 invariant is enforced symmetrically — both `encodeErrorPayload` and `decodeErrorPayload` reject `GENERATION_STALE` payloads without `responderGeneration`. This is intentional: a peer that lacks the data to refresh the requester's index has no business asking the requester to back off; it should send `INTERNAL` instead. The `ChunkErrorCode` enum is closed (the Zod schema uses `z.enum([...])`) so adding new error codes requires updating the schema, the type, and the protocol spec in lockstep.
|
||||
|
||||
The codec is intentionally JSON-only — no compressed binary fields, no base64-wrapped Uint8Arrays. This keeps the error path light enough to fire on every malformed envelope without contributing significant CPU or memory pressure during incident-storm conditions when half the mesh might be sending error envelopes simultaneously. The optional fields (`chunkId`, `retryAfterMs`, `responderGeneration`) flow through the schema with `.optional()` so consumers can detect their presence at the type level without runtime narrowing helpers.
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Codec for `cd:error` envelopes (DMP-18, DMP-19).
|
||||
*
|
||||
* `ChunkErrorPayload` is small and JSON-only — there are no compressed
|
||||
* binary fields. The codec validates against the strict Zod schema in
|
||||
* both directions; `code: 'GENERATION_STALE'` requires the responder's
|
||||
* current `generation` so requesters can refresh their peer index in
|
||||
* the same round trip (DMP-19).
|
||||
*/
|
||||
|
||||
import { DMP_CODES, DmpError } from "../error-codes.js";
|
||||
import { ChunkErrorPayloadSchema } from "../schemas/envelope.schema.js";
|
||||
import type { ChunkErrorPayload } from "../types/protocol.types.js";
|
||||
import { parseStrict } from "./common.js";
|
||||
|
||||
export function encodeErrorPayload(payload: ChunkErrorPayload): Uint8Array {
|
||||
const validated = parseStrict<ChunkErrorPayload>(
|
||||
ChunkErrorPayloadSchema,
|
||||
payload,
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"error payload"
|
||||
);
|
||||
if (
|
||||
validated.code === "GENERATION_STALE" &&
|
||||
validated.responderGeneration === undefined
|
||||
) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"GENERATION_STALE error MUST include responderGeneration"
|
||||
);
|
||||
}
|
||||
return new TextEncoder().encode(JSON.stringify(validated));
|
||||
}
|
||||
|
||||
export function decodeErrorPayload(bytes: Uint8Array): ChunkErrorPayload {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch (err) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
`error payload is not valid JSON: ${String(err)}`
|
||||
);
|
||||
}
|
||||
const validated = parseStrict<ChunkErrorPayload>(
|
||||
ChunkErrorPayloadSchema,
|
||||
parsed,
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"error payload"
|
||||
);
|
||||
if (
|
||||
validated.code === "GENERATION_STALE" &&
|
||||
validated.responderGeneration === undefined
|
||||
) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"GENERATION_STALE error MUST include responderGeneration"
|
||||
);
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export {
|
||||
decodeAdvertisePayload,
|
||||
encodeAdvertisePayload,
|
||||
} from "./advertise.js";
|
||||
export {
|
||||
BITSET_HEADER_SIZE,
|
||||
BITSET_MAGIC,
|
||||
chunkIdsToRawBitset,
|
||||
type DecodedBitset,
|
||||
decodeBitset,
|
||||
encodeBitset,
|
||||
popcount,
|
||||
rawBitsetToChunkIds,
|
||||
} from "./bitset.js";
|
||||
export { decodeErrorPayload, encodeErrorPayload } from "./error.js";
|
||||
export {
|
||||
decodeRequestPayload,
|
||||
encodeRequest,
|
||||
encodeRequestPayload,
|
||||
} from "./request.js";
|
||||
export {
|
||||
decodeResponsePayload,
|
||||
encodeResponsePayload,
|
||||
} from "./response.js";
|
||||
@@ -0,0 +1,62 @@
|
||||
# request
|
||||
|
||||
## Purpose
|
||||
|
||||
Encodes and decodes `cd:request` envelope payloads for the chunk-request side of the mesh protocol. Owns the wire format that downstream packages (`drive-mesh`, `network-plugin-content-delivery`) round-trip through bus transports, and enforces the DMP-01 / DMP-03 / DMP-07 / DMP-21 / DMP-22 invariants at every entry point so that no malformed payload ever reaches the logic layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `encodeRequestPayload(payload)` — schema-validates, enforces `maxChunks` cap, rejects empty `kind:'specific'` arrays, base64-wraps bitset bytes, and returns UTF-8 JSON bytes.
|
||||
- `decodeRequestPayload(bytes)` — inverse with the same gates plus the `bitpacker-compressed` magic check on bitset payloads (DMP-21).
|
||||
- `encodeRequest(request)` — lower-level helper that converts a runtime `ChunkRequest` to its JSON-friendly wire shape.
|
||||
- Strong typing: every public surface is parameterised by `ChunkRequestPayload` from `@suaveplan/drive-mesh-protocol/types`.
|
||||
- Errors are coded: `DMP.EMPTY_REQUEST`, `DMP.MAX_CHUNKS_EXCEEDED`, `DMP.ENVELOPE_MALFORMED`, `DMP.CODEC_MISMATCH`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeRequestPayload,
|
||||
encodeRequestPayload,
|
||||
selectWhole,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const wire = encodeRequestPayload({
|
||||
fileId: "a".repeat(64),
|
||||
request: selectWhole(),
|
||||
priority: "normal",
|
||||
});
|
||||
const back = decodeRequestPayload(wire);
|
||||
console.log(back.request.kind); // "whole"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
chunkIdsToRawBitset,
|
||||
encodeFramedBitset,
|
||||
encodeRequestPayload,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const need = [3, 7, 200, 999];
|
||||
const raw = chunkIdsToRawBitset(need, 1000);
|
||||
const framed = encodeFramedBitset(raw, 1000);
|
||||
const wire = encodeRequestPayload({
|
||||
fileId,
|
||||
request: { kind: "bitset", bytes: framed },
|
||||
priority: "high",
|
||||
maxChunks: 256,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encodeRequestPayload(payload: ChunkRequestPayload): Uint8Array`.
|
||||
- `decodeRequestPayload(bytes: Uint8Array): ChunkRequestPayload`.
|
||||
- `encodeRequest(request: ChunkRequest): WireRequest` — JSON-friendly wire shape; `bitset` form's `bytes` becomes `{ _b: <base64> }`.
|
||||
- Throws `DmpError` with one of the namespaced codes above for every rejection path.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Bitset bytes round-trip through base64 so JSON parsers don't lose the high bit of any byte. The Zod schema deliberately permits empty `chunkIds` arrays so the explicit DMP-03 / DMP-22 gate (which throws `DMP.EMPTY_REQUEST` rather than `DMP.ENVELOPE_MALFORMED`) is the visible failure path. The `maxChunks` schema check is lower-bound only; the upper-bound mandate (DMP-07) lives in `enforceMaxChunks` so the failure carries the spec-mandated `DMP.MAX_CHUNKS_EXCEEDED` code.
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Codec for `cd:request` envelopes (DMP-01..05, DMP-07, DMP-21..22).
|
||||
*
|
||||
* Wire format: a single UTF-8-encoded JSON document containing the
|
||||
* `ChunkRequestPayload` shape. `bytes` for `kind: 'bitset'` requests is
|
||||
* itself a wire-format bitset (`@suaveplan/bitpacker-compressed`-framed
|
||||
* via `codec/bitset.encodeBitset`); on the JSON layer it is encoded as
|
||||
* `{ "_b": "<base64>" }` so JSON parsers don't lose the high bit of any
|
||||
* byte. Receivers detect the bitset framing magic on decode and reject
|
||||
* non-bitpacker-compressed payloads with `DMP.CODEC_MISMATCH`.
|
||||
*/
|
||||
|
||||
import { DMP_CODES, DmpError } from "../error-codes.js";
|
||||
import {
|
||||
ChunkRequestPayloadSchema,
|
||||
MAX_CHUNKS_DEFAULT,
|
||||
MAX_CHUNKS_HARD_CAP,
|
||||
} from "../schemas/envelope.schema.js";
|
||||
import type {
|
||||
ChunkRequest,
|
||||
ChunkRequestPayload,
|
||||
} from "../types/protocol.types.js";
|
||||
import { BITSET_MAGIC, decodeBitset } from "./bitset.js";
|
||||
import { fromBase64, parseStrict, toBase64 } from "./common.js";
|
||||
|
||||
interface WireRequest {
|
||||
readonly kind: "whole" | "specific" | "bitset";
|
||||
readonly chunkIds?: readonly number[];
|
||||
readonly bytes?: { readonly _b: string };
|
||||
}
|
||||
|
||||
interface WirePayload {
|
||||
readonly fileId: string;
|
||||
readonly request: WireRequest;
|
||||
readonly priority: ChunkRequestPayload["priority"];
|
||||
readonly maxChunks?: number;
|
||||
}
|
||||
|
||||
export function encodeRequestPayload(payload: ChunkRequestPayload): Uint8Array {
|
||||
const validated = parseStrict<ChunkRequestPayload>(
|
||||
ChunkRequestPayloadSchema,
|
||||
payload,
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"request payload"
|
||||
);
|
||||
enforceMaxChunks(validated);
|
||||
if (
|
||||
validated.request.kind === "specific" &&
|
||||
validated.request.chunkIds.length === 0
|
||||
) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.EMPTY_REQUEST,
|
||||
"kind:'specific' with empty chunkIds is rejected"
|
||||
);
|
||||
}
|
||||
const wire: WirePayload = {
|
||||
fileId: validated.fileId,
|
||||
priority: validated.priority,
|
||||
request: encodeRequest(validated.request),
|
||||
...(validated.maxChunks !== undefined
|
||||
? { maxChunks: validated.maxChunks }
|
||||
: {}),
|
||||
};
|
||||
return new TextEncoder().encode(JSON.stringify(wire));
|
||||
}
|
||||
|
||||
export function decodeRequestPayload(bytes: Uint8Array): ChunkRequestPayload {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch (err) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
`request payload is not valid JSON: ${String(err)}`
|
||||
);
|
||||
}
|
||||
// DMP-01 explicit-kind gate: pre-validate the discriminator so an
|
||||
// unknown `kind` surfaces as `DMP.UNKNOWN_REQUEST_KIND` rather than
|
||||
// the generic schema rejection.
|
||||
if (
|
||||
parsed !== null &&
|
||||
typeof parsed === "object" &&
|
||||
"request" in parsed &&
|
||||
typeof (parsed as { request: unknown }).request === "object" &&
|
||||
(parsed as { request: { kind?: unknown } }).request !== null
|
||||
) {
|
||||
const k = (parsed as { request: { kind?: unknown } }).request.kind;
|
||||
if (
|
||||
typeof k === "string" &&
|
||||
k !== "whole" &&
|
||||
k !== "specific" &&
|
||||
k !== "bitset"
|
||||
) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.UNKNOWN_REQUEST_KIND,
|
||||
`unknown ChunkRequest kind: ${k}`
|
||||
);
|
||||
}
|
||||
}
|
||||
const validated = parseStrict<ChunkRequestPayload>(
|
||||
ChunkRequestPayloadSchema,
|
||||
unwrapWireRequest(parsed),
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"request payload"
|
||||
);
|
||||
enforceMaxChunks(validated);
|
||||
if (
|
||||
validated.request.kind === "specific" &&
|
||||
validated.request.chunkIds.length === 0
|
||||
) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.EMPTY_REQUEST,
|
||||
"kind:'specific' with empty chunkIds is rejected; use kind:'whole' for everything"
|
||||
);
|
||||
}
|
||||
if (validated.request.kind === "bitset") {
|
||||
// DMP-21: bitset payload MUST be bitpacker-compressed-framed. The
|
||||
// decode step will throw DMP.CODEC_MISMATCH on missing magic.
|
||||
decodeBitset(validated.request.bytes);
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
|
||||
export function encodeRequest(request: ChunkRequest): WireRequest {
|
||||
if (request.kind === "whole") return { kind: "whole" };
|
||||
if (request.kind === "specific") {
|
||||
if (request.chunkIds.length === 0) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.EMPTY_REQUEST,
|
||||
"kind:'specific' with empty chunkIds is rejected"
|
||||
);
|
||||
}
|
||||
return {
|
||||
kind: "specific",
|
||||
chunkIds: request.chunkIds,
|
||||
};
|
||||
}
|
||||
assertBitsetMagic(request.bytes);
|
||||
return {
|
||||
kind: "bitset",
|
||||
bytes: { _b: toBase64(request.bytes) },
|
||||
};
|
||||
}
|
||||
|
||||
function enforceMaxChunks(payload: ChunkRequestPayload): void {
|
||||
const max = payload.maxChunks ?? MAX_CHUNKS_DEFAULT;
|
||||
if (max > MAX_CHUNKS_HARD_CAP) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.MAX_CHUNKS_EXCEEDED,
|
||||
`maxChunks ${max} > hard cap ${MAX_CHUNKS_HARD_CAP}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertBitsetMagic(bytes: Uint8Array): void {
|
||||
if (
|
||||
bytes.length < BITSET_MAGIC.length ||
|
||||
!BITSET_MAGIC.every((b, i) => bytes[i] === b)
|
||||
) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.CODEC_MISMATCH,
|
||||
"bitset payload missing the @suaveplan/bitpacker-compressed framing magic"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapWireRequest(parsed: unknown): unknown {
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== "object" ||
|
||||
!("request" in parsed)
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
const wire = parsed as WirePayload;
|
||||
const request = wire.request;
|
||||
if (request.kind === "bitset" && request.bytes) {
|
||||
return {
|
||||
...wire,
|
||||
request: {
|
||||
kind: "bitset",
|
||||
bytes: fromBase64(request.bytes._b),
|
||||
},
|
||||
};
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# response
|
||||
|
||||
## Purpose
|
||||
|
||||
Encodes and decodes `cd:response` envelope payloads — the responder-side messages that carry chunk bytes back to a requester plus a piggyback presence bitset (DMP-11) and generation counter. Enforces DMP-12 (chunks ordered by `chunkId` ascending), DMP-21 (presence framed via the bitset codec), and the standard schema gates.
|
||||
|
||||
## Features
|
||||
|
||||
- `encodeResponsePayload(payload)` — Zod-validates, asserts ascending chunk-id order, cross-checks presence framing, base64-wraps every chunk's bytes plus the presence buffer, emits UTF-8 JSON.
|
||||
- `decodeResponsePayload(bytes)` — inverse, with the same ordering and framing checks so receivers can short-circuit on the first ordering violation.
|
||||
- Order-violation surface: `DMP.UNORDERED_RESPONSE`. Other failures route to `DMP.ENVELOPE_MALFORMED` / `DMP.CODEC_MISMATCH`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
chunkIdsToRawBitset,
|
||||
decodeResponsePayload,
|
||||
encodeFramedBitset,
|
||||
encodeResponsePayload,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const presence = encodeFramedBitset(chunkIdsToRawBitset([0, 5], 8), 8);
|
||||
const wire = encodeResponsePayload({
|
||||
fileId: "f".repeat(64),
|
||||
chunks: [
|
||||
{ chunkId: 0, bytes: new Uint8Array([0xa1]) },
|
||||
{ chunkId: 5, bytes: new Uint8Array([0xa2]) },
|
||||
],
|
||||
presence,
|
||||
generation: 11,
|
||||
});
|
||||
const back = decodeResponsePayload(wire);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeFramedBitset,
|
||||
decodeResponsePayload,
|
||||
rawBitsetToChunkIds,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
function ingestResponse(bytes: Uint8Array, peerId: string): void {
|
||||
const r = decodeResponsePayload(bytes);
|
||||
// Apply DMP-11 piggyback: update peer-index for (peerId, r.fileId)
|
||||
// from r.presence without waiting for a separate cd:advertise.
|
||||
const presence = decodeFramedBitset(r.presence);
|
||||
const inventory = rawBitsetToChunkIds(presence.raw, presence.bitLength);
|
||||
updatePeerIndex(peerId, r.fileId, inventory, r.generation);
|
||||
for (const c of r.chunks) commitVerifiedChunk(r.fileId, c.chunkId, c.bytes);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encodeResponsePayload(payload: ChunkResponsePayload): Uint8Array`.
|
||||
- `decodeResponsePayload(bytes: Uint8Array): ChunkResponsePayload`.
|
||||
- Errors: `DMP.UNORDERED_RESPONSE`, `DMP.ENVELOPE_MALFORMED`, `DMP.CODEC_MISMATCH`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The ordering invariant (DMP-12) is checked twice — once at encode time so issuers can't accidentally emit a malformed response, and once at decode time to defend against tampered wire bytes. The order check uses strict `<=` so duplicate chunkIds are rejected too; this matters because a duplicate at index `i` would otherwise let a downstream consumer commit the same chunk twice and double-increment the trust counters. The piggyback presence MUST go through the bitset framing's magic check on every decode so misframed payloads can't slip past as base64-wrapped JSON strings.
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Codec for `cd:response` envelopes (DMP-11, DMP-12, DMP-21).
|
||||
*
|
||||
* Response payloads carry an array of `ChunkBytes` ordered by `chunkId`
|
||||
* ascending plus a piggyback presence bitset for the responder's full
|
||||
* inventory. Encoder and decoder both reject out-of-order chunks with
|
||||
* `DMP.UNORDERED_RESPONSE`. Both fields cross JSON as base64 strings;
|
||||
* the presence bitset additionally goes through the
|
||||
* `bitpacker-compressed` framing magic check (DMP-21).
|
||||
*/
|
||||
|
||||
import { DMP_CODES, DmpError } from "../error-codes.js";
|
||||
import { ChunkResponsePayloadSchema } from "../schemas/envelope.schema.js";
|
||||
import type {
|
||||
ChunkBytes,
|
||||
ChunkResponsePayload,
|
||||
} from "../types/protocol.types.js";
|
||||
import { decodeBitset } from "./bitset.js";
|
||||
import { fromBase64, parseStrict, toBase64 } from "./common.js";
|
||||
|
||||
interface WireChunk {
|
||||
readonly chunkId: number;
|
||||
readonly bytes: { readonly _b: string };
|
||||
}
|
||||
|
||||
interface WirePayload {
|
||||
readonly fileId: string;
|
||||
readonly chunks: readonly WireChunk[];
|
||||
readonly presence: { readonly _b: string };
|
||||
readonly generation: number;
|
||||
}
|
||||
|
||||
export function encodeResponsePayload(
|
||||
payload: ChunkResponsePayload
|
||||
): Uint8Array {
|
||||
const validated = parseStrict<ChunkResponsePayload>(
|
||||
ChunkResponsePayloadSchema,
|
||||
payload,
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"response payload"
|
||||
);
|
||||
assertOrdered(validated.chunks);
|
||||
// DMP-21: presence MUST already be bitpacker-compressed-framed.
|
||||
decodeBitset(validated.presence);
|
||||
const wire: WirePayload = {
|
||||
fileId: validated.fileId,
|
||||
chunks: validated.chunks.map((c) => ({
|
||||
chunkId: c.chunkId,
|
||||
bytes: { _b: toBase64(c.bytes) },
|
||||
})),
|
||||
presence: { _b: toBase64(validated.presence) },
|
||||
generation: validated.generation,
|
||||
};
|
||||
return new TextEncoder().encode(JSON.stringify(wire));
|
||||
}
|
||||
|
||||
export function decodeResponsePayload(bytes: Uint8Array): ChunkResponsePayload {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch (err) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
`response payload is not valid JSON: ${String(err)}`
|
||||
);
|
||||
}
|
||||
const validated = parseStrict<ChunkResponsePayload>(
|
||||
ChunkResponsePayloadSchema,
|
||||
unwrapWire(parsed),
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
"response payload"
|
||||
);
|
||||
assertOrdered(validated.chunks);
|
||||
decodeBitset(validated.presence);
|
||||
return validated;
|
||||
}
|
||||
|
||||
function assertOrdered(chunks: readonly ChunkBytes[]): void {
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
const prev = chunks[i - 1] as ChunkBytes;
|
||||
const cur = chunks[i] as ChunkBytes;
|
||||
if (cur.chunkId <= prev.chunkId) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.UNORDERED_RESPONSE,
|
||||
`chunk ${cur.chunkId} appears at index ${i} after ${prev.chunkId} — chunks must be strictly ascending`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapWire(parsed: unknown): unknown {
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== "object" ||
|
||||
!("chunks" in parsed) ||
|
||||
!("presence" in parsed)
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
const wire = parsed as WirePayload;
|
||||
return {
|
||||
...wire,
|
||||
chunks: wire.chunks.map((c) => ({
|
||||
chunkId: c.chunkId,
|
||||
bytes: fromBase64(c.bytes._b),
|
||||
})),
|
||||
presence: fromBase64(wire.presence._b),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# DMP_CODES + DmpError
|
||||
|
||||
## Purpose
|
||||
|
||||
Frozen registry of error codes plus the error class for the `@suaveplan/drive-mesh-protocol` package. Every rejection path in the mesh protocol — empty `kind: 'specific'` requests, unknown discriminator values, exceeded `maxChunks`, malformed bitsets, out-of-order responses, stale generations, codec mismatches — produces a `DmpError` whose `code` field is one of the eight strings registered here. Consumers branch on `code` programmatically; messages are for humans only.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight codes covering every rejection path in the mesh-protocol spec (`DMP-01..DMP-22`).
|
||||
- `DMP_CODES` is frozen via `defineCodes` from `@suaveplan/error` — adding a code requires a code change and a spec update.
|
||||
- `DmpError extends SuaveplanError<DmpErrorCode>` — inherits cause-chain traversal, redaction, and JSON wire format.
|
||||
- Codes are typed at compile time (`DmpErrorCode`) so consumers get exhaustive-switch type checking on error code branches.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DmpError, DMP_CODES } from "@suaveplan/drive-mesh-protocol/errors";
|
||||
|
||||
throw new DmpError(DMP_CODES.EMPTY_REQUEST, "kind:'specific' requires non-empty chunkIds");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DmpError, DMP_CODES, type DmpErrorCode } from "@suaveplan/drive-mesh-protocol/errors";
|
||||
|
||||
function describe(code: DmpErrorCode): string {
|
||||
switch (code) {
|
||||
case DMP_CODES.EMPTY_REQUEST: return "kind:'specific' with no chunkIds is a no-op; use kind:'whole' instead";
|
||||
case DMP_CODES.UNKNOWN_REQUEST_KIND: return "request.kind must be one of 'whole' | 'specific' | 'bitset'";
|
||||
case DMP_CODES.MAX_CHUNKS_EXCEEDED: return "maxChunks > 1024 is rejected; clamp to a sensible value";
|
||||
case DMP_CODES.BITSET_LENGTH_MISMATCH: return "bitset bit-length must equal totalChunks";
|
||||
case DMP_CODES.BITSET_PADDING_NONZERO: return "padding bits in the final byte must be zero";
|
||||
case DMP_CODES.UNORDERED_RESPONSE: return "ChunkResponsePayload.chunks must be ordered by chunkId asc";
|
||||
case DMP_CODES.GENERATION_STALE: return "responder's generation > requester's; refresh peer-index";
|
||||
case DMP_CODES.CODEC_MISMATCH: return "envelope not framed via @suaveplan/bitpacker-network";
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
throw new DmpError(DMP_CODES.MAX_CHUNKS_EXCEEDED, "request asked for 4096 chunks");
|
||||
} catch (err) {
|
||||
if (err instanceof DmpError) {
|
||||
console.error(`[${err.code}] ${describe(err.code)}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `const DMP_CODES`
|
||||
|
||||
Frozen object mapping uppercase code names to their wire-format strings:
|
||||
|
||||
- `EMPTY_REQUEST` — `kind: 'specific'` envelope arrived with `chunkIds: []`. Disambiguates from `kind: 'whole'`.
|
||||
- `UNKNOWN_REQUEST_KIND` — discriminator field is not one of `'whole' | 'specific' | 'bitset'`. Likely a future-version envelope or a malformed wire.
|
||||
- `MAX_CHUNKS_EXCEEDED` — `request.maxChunks > 1024`. Validation rejects to prevent unbounded responses.
|
||||
- `BITSET_LENGTH_MISMATCH` — `presence` bit-length differs from `totalChunks`. Wire format is corrupted.
|
||||
- `BITSET_PADDING_NONZERO` — padding bits in the final byte of a bitset are non-zero. Wire format is malformed.
|
||||
- `UNORDERED_RESPONSE` — `ChunkResponsePayload.chunks` is not ordered by `chunkId` ascending. Allows incremental presence updates only when ordered.
|
||||
- `GENERATION_STALE` — responder rejected the request because its generation counter is ahead of the requester's. Includes responder's current generation in the error context for cheap recovery.
|
||||
- `CODEC_MISMATCH` — envelope is not framed via `@suaveplan/bitpacker-network`. Indicates a wire-format mismatch (different protocol version, different codec).
|
||||
|
||||
### `type DmpErrorCode`
|
||||
|
||||
Union: `(typeof DMP_CODES)[keyof typeof DMP_CODES]`. The string-literal union of every registered code.
|
||||
|
||||
### `class DmpError extends SuaveplanError<DmpErrorCode>`
|
||||
|
||||
Constructor: `new DmpError(code, message, options?)`. Inherits all `SuaveplanError` features (cause chain, context bag, OTel span-event adapter, JSON/binary wire format).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The codes are intentionally generic — they describe wire-protocol invariant violations, not specific peer behaviors. Trust-related conditions (peer advertised but didn't deliver, peer's signature failed) do NOT throw `DmpError`; they update the per-`(peerId, fileId)` trust score in the `select/trust` module without raising errors. Errors are reserved for situations where the local code cannot proceed: malformed envelopes, bad inputs, codec mismatches.
|
||||
|
||||
The `DMP_` prefix is mandated by `defineCodes("DMP", ...)`. The `error-codes.spec-conformance.test.ts` enforces (1) the registry is frozen, (2) every code value starts with `DMP.`, (3) the eight names match what the spec requires.
|
||||
|
||||
When the mesh protocol's wire format evolves, new codes are ADDED — never renamed or removed — and the `DMP_CODES` object grows. Tests with `expect.assertions` patterns continue to work because the literal union widens monotonically.
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Frozen registry of error codes for the mesh protocol package.
|
||||
*
|
||||
* Every error thrown by `@suaveplan/drive-mesh-protocol` carries one of
|
||||
* these codes so consumers can branch programmatically without parsing
|
||||
* messages.
|
||||
*/
|
||||
export const DMP_CODES = defineCodes("DMP", [
|
||||
"EMPTY_REQUEST",
|
||||
"UNKNOWN_REQUEST_KIND",
|
||||
"MAX_CHUNKS_EXCEEDED",
|
||||
"BITSET_LENGTH_MISMATCH",
|
||||
"BITSET_PADDING_NONZERO",
|
||||
"UNORDERED_RESPONSE",
|
||||
"GENERATION_STALE",
|
||||
"CODEC_MISMATCH",
|
||||
"ENVELOPE_MALFORMED",
|
||||
"THROTTLE_EXCEEDED",
|
||||
] as const);
|
||||
|
||||
/** Union of every code in `DMP_CODES`. */
|
||||
export type DmpErrorCode = (typeof DMP_CODES)[keyof typeof DMP_CODES];
|
||||
|
||||
/**
|
||||
* Error class for the mesh-protocol package. Extends `SuaveplanError`
|
||||
* so consumers can `instanceof DmpError` and access `code`, `cause`,
|
||||
* `context`, and the structured wire-format helpers.
|
||||
*/
|
||||
export class DmpError extends SuaveplanError<DmpErrorCode> {
|
||||
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
|
||||
public constructor(
|
||||
code: DmpErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
type AcceptResult,
|
||||
GenerationTracker,
|
||||
} from "./monotonic.js";
|
||||
export {
|
||||
AdvertiseThrottle,
|
||||
type ThrottleResult,
|
||||
} from "./throttle.js";
|
||||
@@ -0,0 +1,62 @@
|
||||
# monotonic
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `GenerationTracker`, the receiver-side state machine that keeps the highest seen `generation` per `(peerId, fileId)` and drops stale advertisements (DMP-09). Generations are unsigned 32-bit; receivers tolerate a one-cycle wrap when `(lastSeen - generation) > 2³¹` to handle the rare anomaly of a peer that genuinely wraps past `0xFFFF_FFFF`.
|
||||
|
||||
## Features
|
||||
|
||||
- `observe(peerId, fileId, generation)` — returns `{ accept, latest, reason? }` with `reason: "stale" | "duplicate"` for rejected envelopes.
|
||||
- `peek(peerId, fileId)` — read the current highest without mutating.
|
||||
- `dropPeer(peerId)` / `dropFile(fileId)` — registry-driven cleanup hooks.
|
||||
- Wrap detection via `GENERATION_WRAP_THRESHOLD` (2³¹) to discriminate "out-of-order" from "wrap-around".
|
||||
- `size` getter for snapshot/persistence inspection.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { GenerationTracker } from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const tracker = new GenerationTracker();
|
||||
tracker.observe("peer-A", "fileId", 7);
|
||||
const r = tracker.observe("peer-A", "fileId", 6);
|
||||
console.log(r.accept); // false (stale)
|
||||
console.log(r.reason); // "stale"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeAdvertisePayload,
|
||||
GenerationTracker,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const tracker = new GenerationTracker();
|
||||
|
||||
function handleIncomingAdvertise(
|
||||
bytes: Uint8Array,
|
||||
peerId: string
|
||||
): void {
|
||||
const adv = decodeAdvertisePayload(bytes);
|
||||
const result = tracker.observe(peerId, adv.fileId, adv.generation);
|
||||
if (!result.accept) {
|
||||
// DMP-09: drop stale or duplicate advertisements silently.
|
||||
return;
|
||||
}
|
||||
updatePeerIndex(peerId, adv.fileId, adv.presence);
|
||||
}
|
||||
|
||||
function onPeerLeft(peerId: string): void {
|
||||
tracker.dropPeer(peerId);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class GenerationTracker` — `observe(peerId, fileId, generation)`, `peek(peerId, fileId)`, `dropPeer(peerId)`, `dropFile(fileId)`, `size`.
|
||||
- `interface AcceptResult` — `{ accept: boolean; latest: number; reason?: "stale" | "duplicate" }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Negative or non-integer generations short-circuit to `accept: false` rather than throwing; the protocol-level rejection of malformed envelopes happens at the codec layer, and the tracker treats malformed inputs as stale to be defensive. The wrap heuristic (`(lastSeen - generation) > 2³¹`) accepts a single anomaly per `(peerId, fileId)` cycle — a peer that tries to wrap twice in quick succession will be classified as stale on the second attempt, which is the right safety choice given that real wraps are extremely rare.
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Generation-counter monotonicity tracker (DMP-09).
|
||||
*
|
||||
* `ChunkAdvertisePayload.generation` MUST monotonically increase per
|
||||
* `(peerId, fileId)`. Receivers track the highest generation seen and
|
||||
* drop advertisements with `generation < lastSeen`. Generations wrap
|
||||
* modulo 2^32 — receivers MAY accept a wrap when
|
||||
* `(lastSeen - generation) > 2^31` (one-cycle anomaly tolerated).
|
||||
*/
|
||||
|
||||
import type { PeerId } from "@suaveplan/types";
|
||||
import { GENERATION_WRAP_THRESHOLD } from "../schemas/envelope.schema.js";
|
||||
|
||||
interface GenerationEntry {
|
||||
generation: number;
|
||||
}
|
||||
|
||||
function key(peerId: PeerId, fileId: string): string {
|
||||
return `${peerId}:${fileId}`;
|
||||
}
|
||||
|
||||
export interface AcceptResult {
|
||||
/** Whether the advertisement should be accepted. */
|
||||
readonly accept: boolean;
|
||||
/** The new "highest seen" generation after this call. */
|
||||
readonly latest: number;
|
||||
/** Reason a rejected advertisement was dropped (for logs). */
|
||||
readonly reason?: "stale" | "duplicate";
|
||||
}
|
||||
|
||||
export class GenerationTracker {
|
||||
readonly #entries = new Map<string, GenerationEntry>();
|
||||
|
||||
/**
|
||||
* Track the generation of an incoming advertisement and decide
|
||||
* whether to accept it. Returns `accept: false` for stale/duplicate
|
||||
* generations; the caller is expected to drop the envelope.
|
||||
*/
|
||||
public observe(
|
||||
peerId: PeerId,
|
||||
fileId: string,
|
||||
generation: number
|
||||
): AcceptResult {
|
||||
if (
|
||||
generation < 0 ||
|
||||
!Number.isInteger(generation) ||
|
||||
generation > 0xff_ff_ff_ff
|
||||
) {
|
||||
return { accept: false, latest: generation, reason: "stale" };
|
||||
}
|
||||
const k = key(peerId, fileId);
|
||||
const e = this.#entries.get(k);
|
||||
if (!e) {
|
||||
this.#entries.set(k, { generation });
|
||||
return { accept: true, latest: generation };
|
||||
}
|
||||
if (generation === e.generation) {
|
||||
return { accept: false, latest: e.generation, reason: "duplicate" };
|
||||
}
|
||||
if (generation > e.generation) {
|
||||
e.generation = generation;
|
||||
return { accept: true, latest: generation };
|
||||
}
|
||||
// generation < lastSeen — accept only on a one-cycle wrap.
|
||||
if (e.generation - generation > GENERATION_WRAP_THRESHOLD) {
|
||||
e.generation = generation;
|
||||
return { accept: true, latest: generation };
|
||||
}
|
||||
return { accept: false, latest: e.generation, reason: "stale" };
|
||||
}
|
||||
|
||||
/** Read the current highest generation for `(peerId, fileId)`. */
|
||||
public peek(peerId: PeerId, fileId: string): number | undefined {
|
||||
return this.#entries.get(key(peerId, fileId))?.generation;
|
||||
}
|
||||
|
||||
public dropPeer(peerId: PeerId): void {
|
||||
for (const k of [...this.#entries.keys()]) {
|
||||
if (k.startsWith(`${peerId}:`)) {
|
||||
this.#entries.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dropFile(fileId: string): void {
|
||||
for (const k of [...this.#entries.keys()]) {
|
||||
if (k.endsWith(`:${fileId}`)) {
|
||||
this.#entries.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public get size(): number {
|
||||
return this.#entries.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# throttle
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `AdvertiseThrottle`, the issuer-side rate limiter that caps outgoing `cd:advertise` envelopes at one per `(neighbor, fileId)` per `windowMs` interval (DMP-10). Multiple presence changes inside the window are coalesced into the next emission via the `pending` flag so peers gossip efficiently without spamming the bus.
|
||||
|
||||
## Features
|
||||
|
||||
- `allow(neighbor, fileId)` — returns `{ allow, retryAfterMs }` so the caller can either emit immediately or schedule a deferred emission.
|
||||
- `hasPending(neighbor, fileId)` — query for "is an advertisement waiting to flush?".
|
||||
- `clearPending(neighbor, fileId)` — invoked by the caller after the deferred advertisement actually lands.
|
||||
- `dropNeighbor(neighbor)` — registry-driven cleanup.
|
||||
- Configurable `windowMs` capped at `ADVERTISE_THROTTLE_MS_MAX` (60 s); rejected with `DMP.THROTTLE_EXCEEDED` if out of range.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AdvertiseThrottle } from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const throttle = new AdvertiseThrottle({ clock: () => Date.now() });
|
||||
const r = throttle.allow("neighbor-1", "fileId");
|
||||
if (r.allow) {
|
||||
sendAdvertise("neighbor-1", "fileId");
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { AdvertiseThrottle } from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const throttle = new AdvertiseThrottle({
|
||||
clock: () => Date.now(),
|
||||
windowMs: 2_000, // QoS subsystem widened the window during congestion
|
||||
});
|
||||
|
||||
function onPresenceChanged(neighbor: string, fileId: string): void {
|
||||
const r = throttle.allow(neighbor, fileId);
|
||||
if (r.allow) {
|
||||
sendAdvertise(neighbor, fileId);
|
||||
} else {
|
||||
// Coalesce: schedule a single follow-up after the window elapses.
|
||||
if (!throttle.hasPending(neighbor, fileId)) {
|
||||
setTimeout(() => {
|
||||
sendAdvertise(neighbor, fileId);
|
||||
throttle.clearPending(neighbor, fileId);
|
||||
}, r.retryAfterMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new AdvertiseThrottle({ clock, windowMs? })` — `windowMs` defaults to `ADVERTISE_THROTTLE_MS_DEFAULT` (1000).
|
||||
- `allow(neighbor, fileId): ThrottleResult` — `{ allow, retryAfterMs }`.
|
||||
- `hasPending(neighbor, fileId): boolean`.
|
||||
- `clearPending(neighbor, fileId): void`.
|
||||
- `dropNeighbor(neighbor): void`.
|
||||
- `size: number`, `windowMs: number`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The throttle uses an injected `Clock` so tests can step time deterministically. The `pending` flag short-circuits double-flushes when multiple presence changes hit the same window; without it, callers would either drop advertisements or emit redundant ones. The constructor's window-range check fires `DMP.THROTTLE_EXCEEDED` (rather than a generic argument error) so monitoring dashboards can categorise misconfiguration alongside the runtime throttle behaviour.
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Outgoing-advertisement throttle (DMP-10).
|
||||
*
|
||||
* A peer SHALL emit at most one outgoing `cd:advertise` per file per
|
||||
* direct neighbor per `windowMs` interval. The default window is 1
|
||||
* second. Multiple presence changes inside the window are coalesced
|
||||
* into the next emission. The window MAY be adjusted by
|
||||
* `network-plugin-qos` based on bus congestion but SHALL NOT exceed
|
||||
* `ADVERTISE_THROTTLE_MS_MAX` (60 s).
|
||||
*/
|
||||
|
||||
import type { Clock, PeerId } from "@suaveplan/types";
|
||||
import { DMP_CODES, DmpError } from "../error-codes.js";
|
||||
import {
|
||||
ADVERTISE_THROTTLE_MS_DEFAULT,
|
||||
ADVERTISE_THROTTLE_MS_MAX,
|
||||
} from "../schemas/envelope.schema.js";
|
||||
|
||||
interface ThrottleEntry {
|
||||
lastSent: number;
|
||||
pending: boolean;
|
||||
}
|
||||
|
||||
function key(peerId: PeerId, fileId: string): string {
|
||||
return `${peerId}:${fileId}`;
|
||||
}
|
||||
|
||||
export interface ThrottleResult {
|
||||
readonly allow: boolean;
|
||||
/** Milliseconds until the next emission would be allowed. */
|
||||
readonly retryAfterMs: number;
|
||||
}
|
||||
|
||||
export class AdvertiseThrottle {
|
||||
readonly #entries = new Map<string, ThrottleEntry>();
|
||||
readonly #clock: Clock;
|
||||
readonly #windowMs: number;
|
||||
|
||||
public constructor(options: {
|
||||
readonly clock: Clock;
|
||||
readonly windowMs?: number;
|
||||
}) {
|
||||
const window = options.windowMs ?? ADVERTISE_THROTTLE_MS_DEFAULT;
|
||||
if (window <= 0 || window > ADVERTISE_THROTTLE_MS_MAX) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.THROTTLE_EXCEEDED,
|
||||
`windowMs must be in (0, ${ADVERTISE_THROTTLE_MS_MAX}]; got ${window}`
|
||||
);
|
||||
}
|
||||
this.#clock = options.clock;
|
||||
this.#windowMs = window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an outgoing `cd:advertise` for `(neighbor, fileId)` is
|
||||
* permitted right now, and if not, how long to wait.
|
||||
*/
|
||||
public allow(neighbor: PeerId, fileId: string): ThrottleResult {
|
||||
const now = this.#clock();
|
||||
const k = key(neighbor, fileId);
|
||||
const e = this.#entries.get(k);
|
||||
if (!e) {
|
||||
this.#entries.set(k, { lastSent: now, pending: false });
|
||||
return { allow: true, retryAfterMs: 0 };
|
||||
}
|
||||
const elapsed = now - e.lastSent;
|
||||
if (elapsed >= this.#windowMs) {
|
||||
e.lastSent = now;
|
||||
e.pending = false;
|
||||
return { allow: true, retryAfterMs: 0 };
|
||||
}
|
||||
e.pending = true;
|
||||
return { allow: false, retryAfterMs: this.#windowMs - elapsed };
|
||||
}
|
||||
|
||||
/** True when an advertisement was suppressed and is waiting to flush. */
|
||||
public hasPending(neighbor: PeerId, fileId: string): boolean {
|
||||
return this.#entries.get(key(neighbor, fileId))?.pending ?? false;
|
||||
}
|
||||
|
||||
/** Flush the pending bit (call after the suppressed advertisement is sent). */
|
||||
public clearPending(neighbor: PeerId, fileId: string): void {
|
||||
const e = this.#entries.get(key(neighbor, fileId));
|
||||
if (e) e.pending = false;
|
||||
}
|
||||
|
||||
public dropNeighbor(neighbor: PeerId): void {
|
||||
for (const k of [...this.#entries.keys()]) {
|
||||
if (k.startsWith(`${neighbor}:`)) {
|
||||
this.#entries.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public get size(): number {
|
||||
return this.#entries.size;
|
||||
}
|
||||
|
||||
public get windowMs(): number {
|
||||
return this.#windowMs;
|
||||
}
|
||||
}
|
||||
85
packages/networking/drive/drive-mesh-protocol/src/index.ts
Normal file
85
packages/networking/drive/drive-mesh-protocol/src/index.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// Side-effect import ensures DMP_CODES is registered for subpath consumers
|
||||
// that import only from a feature path (and so don't otherwise pull in
|
||||
// the root barrel).
|
||||
import "./error-codes.js";
|
||||
|
||||
export {
|
||||
BITSET_HEADER_SIZE,
|
||||
BITSET_MAGIC,
|
||||
chunkIdsToRawBitset,
|
||||
type DecodedBitset,
|
||||
decodeAdvertisePayload,
|
||||
decodeBitset as decodeFramedBitset,
|
||||
decodeErrorPayload,
|
||||
decodeRequestPayload,
|
||||
decodeResponsePayload,
|
||||
encodeAdvertisePayload,
|
||||
encodeBitset as encodeFramedBitset,
|
||||
encodeErrorPayload,
|
||||
encodeRequest,
|
||||
encodeRequestPayload,
|
||||
encodeResponsePayload,
|
||||
popcount,
|
||||
rawBitsetToChunkIds,
|
||||
} from "./codec/index.js";
|
||||
export { DMP_CODES, DmpError, type DmpErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
type AcceptResult,
|
||||
AdvertiseThrottle,
|
||||
GenerationTracker,
|
||||
type ThrottleResult,
|
||||
} from "./generation/index.js";
|
||||
export {
|
||||
ADVERTISE_THROTTLE_MS_DEFAULT,
|
||||
ADVERTISE_THROTTLE_MS_MAX,
|
||||
ChunkAdvertisePayloadSchema,
|
||||
ChunkBytesSchema,
|
||||
ChunkErrorCodeSchema,
|
||||
ChunkErrorPayloadSchema,
|
||||
ChunkRequestPayloadSchema,
|
||||
ChunkRequestPrioritySchema,
|
||||
ChunkRequestSchema,
|
||||
ChunkResponsePayloadSchema,
|
||||
FileIdSchema,
|
||||
GENERATION_WRAP_THRESHOLD,
|
||||
MAX_CHUNKS_DEFAULT,
|
||||
MAX_CHUNKS_HARD_CAP,
|
||||
SAMPLE_RATE_HEALTHY,
|
||||
SAMPLE_RATE_SUSPECT,
|
||||
SELECT_FORM_BITSET_THRESHOLD,
|
||||
TRUST_DECAY_SLOPE,
|
||||
TRUST_MISS_RATE_FLOOR,
|
||||
TRUST_SUSPECT_THRESHOLD,
|
||||
} from "./schemas/index.js";
|
||||
export {
|
||||
decodeBitsetToChunkIds,
|
||||
encodeBitsetFromChunkIds,
|
||||
type SelectFormOptions,
|
||||
selectForm,
|
||||
selectWhole,
|
||||
} from "./select/index.js";
|
||||
export {
|
||||
classify,
|
||||
probabilityOfSkip,
|
||||
sampleRate,
|
||||
shouldSample,
|
||||
shouldSkip,
|
||||
type TrustEntry,
|
||||
TrustMap,
|
||||
type TrustStatus,
|
||||
} from "./trust/index.js";
|
||||
export type {
|
||||
ChunkAdvertiseEnvelope,
|
||||
ChunkAdvertisePayload,
|
||||
ChunkBytes,
|
||||
ChunkErrorCode,
|
||||
ChunkErrorEnvelope,
|
||||
ChunkErrorPayload,
|
||||
ChunkRequest,
|
||||
ChunkRequestEnvelope,
|
||||
ChunkRequestPayload,
|
||||
ChunkRequestPriority,
|
||||
ChunkResponseEnvelope,
|
||||
ChunkResponsePayload,
|
||||
TrustScore,
|
||||
} from "./types/protocol.types.js";
|
||||
@@ -0,0 +1,61 @@
|
||||
# envelope.schema
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines every Zod boundary schema the codec modules invoke before letting payload bytes touch protocol logic, plus the frozen numeric constants the spec pins (`MAX_CHUNKS_DEFAULT`, `MAX_CHUNKS_HARD_CAP`, `SAMPLE_RATE_HEALTHY`, `SAMPLE_RATE_SUSPECT`, `TRUST_SUSPECT_THRESHOLD`, `TRUST_MISS_RATE_FLOOR`, `TRUST_DECAY_SLOPE`, `ADVERTISE_THROTTLE_MS_DEFAULT`, `ADVERTISE_THROTTLE_MS_MAX`, `GENERATION_WRAP_THRESHOLD`, `SELECT_FORM_BITSET_THRESHOLD`). Centralising the schemas + constants keeps every codec / trust / generation module branchless against ad-hoc magic numbers.
|
||||
|
||||
## Features
|
||||
|
||||
- `FileIdSchema` — hex string, 1–128 chars (`[0-9a-f]+`).
|
||||
- `ChunkRequestSchema` — discriminated union (`whole` / `specific` / `bitset`).
|
||||
- `ChunkRequestPayloadSchema`, `ChunkAdvertisePayloadSchema`, `ChunkResponsePayloadSchema`, `ChunkErrorPayloadSchema`, `ChunkBytesSchema`, `ChunkRequestPrioritySchema`, `ChunkErrorCodeSchema`.
|
||||
- Numeric constants documented inline so monitoring dashboards can reference the same source-of-truth as the codec.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ChunkRequestPayloadSchema,
|
||||
MAX_CHUNKS_HARD_CAP,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const result = ChunkRequestPayloadSchema.safeParse(unknownInput);
|
||||
if (!result.success) throw new Error(result.error.message);
|
||||
const payload = result.data;
|
||||
console.log(MAX_CHUNKS_HARD_CAP); // 1024
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ChunkAdvertisePayloadSchema,
|
||||
ChunkErrorCodeSchema,
|
||||
SELECT_FORM_BITSET_THRESHOLD,
|
||||
TRUST_SUSPECT_THRESHOLD,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
function isSuspectScore(score: number): boolean {
|
||||
return score < TRUST_SUSPECT_THRESHOLD;
|
||||
}
|
||||
|
||||
function pickForm(needed: number[]) {
|
||||
return needed.length <= SELECT_FORM_BITSET_THRESHOLD
|
||||
? "specific"
|
||||
: "bitset";
|
||||
}
|
||||
|
||||
const allErrorCodes = ChunkErrorCodeSchema.options;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Schemas: `FileIdSchema`, `ChunkRequestPrioritySchema`, `ChunkRequestSchema`, `ChunkRequestPayloadSchema`, `ChunkAdvertisePayloadSchema`, `ChunkBytesSchema`, `ChunkResponsePayloadSchema`, `ChunkErrorCodeSchema`, `ChunkErrorPayloadSchema`.
|
||||
- Numeric constants: `MAX_CHUNKS_DEFAULT` (64), `MAX_CHUNKS_HARD_CAP` (1024), `SELECT_FORM_BITSET_THRESHOLD` (64), `SAMPLE_RATE_HEALTHY` (100), `SAMPLE_RATE_SUSPECT` (10), `TRUST_SUSPECT_THRESHOLD` (0.95), `TRUST_MISS_RATE_FLOOR` (0.05), `TRUST_DECAY_SLOPE` (20), `ADVERTISE_THROTTLE_MS_DEFAULT` (1000), `ADVERTISE_THROTTLE_MS_MAX` (60000), `GENERATION_WRAP_THRESHOLD` (2³¹).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `chunkIds` array on `kind:'specific'` is intentionally permissive (no `.min(1)`) so the explicit DMP-03 / DMP-22 gates in the codec (which throw `DMP.EMPTY_REQUEST`) are reachable. The `maxChunks` check is lower-bound only at the schema layer; the upper-bound DMP-07 enforcement lives in the codec so the failure carries the spec-mandated `DMP.MAX_CHUNKS_EXCEEDED` code rather than a generic `DMP.ENVELOPE_MALFORMED`. `// schema: boundary-only` annotations on the priority enum suppress the conformance-script warning about consumer-facing schemas that aren't reflected internally.
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Zod boundary schemas for every wire-format payload exchanged by
|
||||
* `@suaveplan/drive-mesh-protocol`. The codec modules invoke
|
||||
* `safeParse` on the decoded JSON before any logic touches the
|
||||
* payload, satisfying DMP-21's "schema validation before payload
|
||||
* processing" mandate.
|
||||
*/
|
||||
|
||||
import * as z from "zod";
|
||||
|
||||
/** Hex-encoded 32-byte file id; matches the format produced by drive-plugin-chunks. */
|
||||
export const FileIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(/^[0-9a-f]+$/i, "fileId must be lowercase hex");
|
||||
|
||||
/** Five-priority tier (DMP-06). */
|
||||
export const ChunkRequestPrioritySchema = z.enum([
|
||||
"critical",
|
||||
"high",
|
||||
"normal",
|
||||
"low",
|
||||
"bulk",
|
||||
]);
|
||||
|
||||
/** Discriminated union for the three request forms (DMP-01). */
|
||||
export const ChunkRequestSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("whole") }),
|
||||
z.object({
|
||||
kind: z.literal("specific"),
|
||||
// Empty arrays are caught by the explicit DMP-03 / DMP-22 gates in
|
||||
// the codec; keeping the schema permissive lets those throws fire.
|
||||
chunkIds: z.array(z.number().int().nonnegative()),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("bitset"),
|
||||
bytes: z.instanceof(Uint8Array),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const ChunkRequestPayloadSchema = z
|
||||
.object({
|
||||
fileId: FileIdSchema,
|
||||
request: ChunkRequestSchema,
|
||||
priority: ChunkRequestPrioritySchema,
|
||||
// The hard cap is enforced by the codec with `MAX_CHUNKS_EXCEEDED`
|
||||
// so consumers see the spec-mandated DMP-07 code; Zod only checks
|
||||
// that maxChunks is a positive integer.
|
||||
maxChunks: z.number().int().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Hard cap for `maxChunks` per DMP-07. */
|
||||
export const MAX_CHUNKS_HARD_CAP = 1024 as const;
|
||||
|
||||
/** Default `maxChunks` per DMP-07. */
|
||||
export const MAX_CHUNKS_DEFAULT = 64 as const;
|
||||
|
||||
/** Threshold above which a `bitset` request is more compact than `specific` (DMP-05). */
|
||||
export const SELECT_FORM_BITSET_THRESHOLD = 64 as const;
|
||||
|
||||
/** Default 1-in-100 sampling for healthy peers (DMP-17). */
|
||||
export const SAMPLE_RATE_HEALTHY = 100 as const;
|
||||
|
||||
/** Default 1-in-10 sampling for suspect peers (DMP-17). */
|
||||
export const SAMPLE_RATE_SUSPECT = 10 as const;
|
||||
|
||||
/** Trust threshold below which a peer is "suspect" (DMP-17). */
|
||||
export const TRUST_SUSPECT_THRESHOLD = 0.95 as const;
|
||||
|
||||
/** Miss-rate floor below which trust decay is dormant (DMP-15). */
|
||||
export const TRUST_MISS_RATE_FLOOR = 0.05 as const;
|
||||
|
||||
/** Multiplier on (missRate - floor) to compute P(skip) (DMP-15). */
|
||||
export const TRUST_DECAY_SLOPE = 20 as const;
|
||||
|
||||
/** Default throttle window between outgoing advertisements (DMP-10). */
|
||||
export const ADVERTISE_THROTTLE_MS_DEFAULT = 1_000 as const;
|
||||
|
||||
/** Hard upper bound on the throttle window. */
|
||||
export const ADVERTISE_THROTTLE_MS_MAX = 60_000 as const;
|
||||
|
||||
/** Generation wrap detection threshold (DMP-09). */
|
||||
export const GENERATION_WRAP_THRESHOLD = 2 ** 31;
|
||||
|
||||
export const ChunkAdvertisePayloadSchema = z
|
||||
.object({
|
||||
fileId: FileIdSchema,
|
||||
presence: z.instanceof(Uint8Array),
|
||||
totalChunks: z.number().int().min(1),
|
||||
generation: z.number().int().min(0).max(0xff_ff_ff_ff),
|
||||
timestamp: z.number().int().min(0),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const ChunkBytesSchema = z
|
||||
.object({
|
||||
chunkId: z.number().int().nonnegative(),
|
||||
bytes: z.instanceof(Uint8Array),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const ChunkResponsePayloadSchema = z
|
||||
.object({
|
||||
fileId: FileIdSchema,
|
||||
chunks: z.array(ChunkBytesSchema),
|
||||
presence: z.instanceof(Uint8Array),
|
||||
generation: z.number().int().min(0).max(0xff_ff_ff_ff),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const ChunkErrorCodeSchema = z.enum([
|
||||
"NOT_FOUND",
|
||||
"ACCESS_DENIED",
|
||||
"BANDWIDTH_EXCEEDED",
|
||||
"GENERATION_STALE",
|
||||
"INTERNAL",
|
||||
]);
|
||||
|
||||
export const ChunkErrorPayloadSchema = z
|
||||
.object({
|
||||
fileId: FileIdSchema,
|
||||
correlationId: z.string().min(1),
|
||||
code: ChunkErrorCodeSchema,
|
||||
chunkId: z.number().int().nonnegative().optional(),
|
||||
retryAfterMs: z.number().int().nonnegative().optional(),
|
||||
responderGeneration: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(0xff_ff_ff_ff)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,22 @@
|
||||
export {
|
||||
ADVERTISE_THROTTLE_MS_DEFAULT,
|
||||
ADVERTISE_THROTTLE_MS_MAX,
|
||||
ChunkAdvertisePayloadSchema,
|
||||
ChunkBytesSchema,
|
||||
ChunkErrorCodeSchema,
|
||||
ChunkErrorPayloadSchema,
|
||||
ChunkRequestPayloadSchema,
|
||||
ChunkRequestPrioritySchema,
|
||||
ChunkRequestSchema,
|
||||
ChunkResponsePayloadSchema,
|
||||
FileIdSchema,
|
||||
GENERATION_WRAP_THRESHOLD,
|
||||
MAX_CHUNKS_DEFAULT,
|
||||
MAX_CHUNKS_HARD_CAP,
|
||||
SAMPLE_RATE_HEALTHY,
|
||||
SAMPLE_RATE_SUSPECT,
|
||||
SELECT_FORM_BITSET_THRESHOLD,
|
||||
TRUST_DECAY_SLOPE,
|
||||
TRUST_MISS_RATE_FLOOR,
|
||||
TRUST_SUSPECT_THRESHOLD,
|
||||
} from "./envelope.schema.js";
|
||||
103
packages/networking/drive/drive-mesh-protocol/src/select/form.md
Normal file
103
packages/networking/drive/drive-mesh-protocol/src/select/form.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# selectForm
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The form-selection helpers that pick the most compact `ChunkRequest` form for a given set of needed chunks. The mesh protocol defines three forms (`whole` / `specific` / `bitset`); `selectForm()` picks `specific` when the count is small and `bitset` when the count is large. The threshold (`SELECT_FORM_BITSET_THRESHOLD = 64`) matches the empirical bytes-on-wire break-even where a packed bitset becomes smaller than an array of 32-bit chunk ids. The module also ships `selectWhole()` for the unambiguous "request everything" case and `encodeBitset()` / `decodeBitset()` helpers for the bitset payload.
|
||||
|
||||
## Features
|
||||
|
||||
- Auto-picks the optimal form based on chunk count.
|
||||
- Rejects ambiguous empty-array requests with a `DMP.EMPTY_REQUEST` error so callers cannot accidentally express "nothing" as "specific with empty array."
|
||||
- Bitset encoding is zero-deps — no bitpacker dependency in this module — so the function is usable from environments that haven't loaded `@suaveplan/bitpacker-compressed` yet.
|
||||
- Final-byte padding bits are guaranteed zero per DMP-08.
|
||||
- Roundtrip property: `decodeBitset(encodeBitset(needed)) === needed.toSorted((a, b) => a - b)` for any non-empty list of non-negative integers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { selectForm } from "@suaveplan/drive-mesh-protocol/select";
|
||||
|
||||
const small = selectForm([0, 5, 17]);
|
||||
// → { kind: 'specific', chunkIds: [0, 5, 17] }
|
||||
|
||||
const large = selectForm(Array.from({ length: 100 }, (_, i) => i));
|
||||
// → { kind: 'bitset', bytes: <13-byte bitset> }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeBitset,
|
||||
encodeBitset,
|
||||
selectForm,
|
||||
selectWhole,
|
||||
SELECT_FORM_BITSET_THRESHOLD,
|
||||
} from "@suaveplan/drive-mesh-protocol/select";
|
||||
|
||||
function describePlan(needed: readonly number[]) {
|
||||
if (needed.length === 0) return selectWhole();
|
||||
return selectForm(needed);
|
||||
}
|
||||
|
||||
// Adaptive request: ask for everything on the first request, then
|
||||
// switch to selectForm() once we have a partial inventory.
|
||||
const firstRequest = describePlan([]);
|
||||
// → { kind: 'whole' }
|
||||
|
||||
const stillMissing = [200, 201, 202, 203, 204, 205];
|
||||
const followUp = describePlan(stillMissing);
|
||||
// → { kind: 'specific', chunkIds: [200, 201, 202, 203, 204, 205] }
|
||||
|
||||
// Roundtrip check
|
||||
const bitset = encodeBitset(stillMissing);
|
||||
const recovered = decodeBitset(bitset);
|
||||
console.log(recovered);
|
||||
// → [200, 201, 202, 203, 204, 205]
|
||||
|
||||
// Threshold introspection
|
||||
console.log(SELECT_FORM_BITSET_THRESHOLD); // 64
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `const SELECT_FORM_BITSET_THRESHOLD`
|
||||
|
||||
The literal `64`. `selectForm` returns `kind: 'specific'` when `needed.length <= 64` and `kind: 'bitset'` otherwise. Documented as a `const` so consumers can reference the threshold without hardcoding it.
|
||||
|
||||
### `function selectForm(needed): ChunkRequest`
|
||||
|
||||
- `needed: readonly number[]` — the chunk ids to request.
|
||||
- Returns `{ kind: 'specific', chunkIds }` when `needed.length <= SELECT_FORM_BITSET_THRESHOLD`.
|
||||
- Returns `{ kind: 'bitset', bytes }` otherwise.
|
||||
- Throws `DmpError(DMP.EMPTY_REQUEST)` when `needed.length === 0`.
|
||||
|
||||
### `function selectWhole(): ChunkRequest`
|
||||
|
||||
Returns the canonical `{ kind: 'whole' }` form. Use this for "request everything the holder has."
|
||||
|
||||
### `function encodeBitset(needed): Uint8Array`
|
||||
|
||||
- `needed: readonly number[]` — non-negative integers to set bits for.
|
||||
- Returns a `Uint8Array` of length `Math.ceil((max(needed) + 1) / 8)`.
|
||||
- Bit `i` is set iff `i ∈ needed`. Padding bits in the final byte are zero.
|
||||
- Throws `DmpError(DMP.BITSET_LENGTH_MISMATCH)` when any element is negative or non-integer.
|
||||
|
||||
### `function decodeBitset(bytes, bitLength?): number[]`
|
||||
|
||||
- `bytes: Uint8Array` — the packed bitset.
|
||||
- `bitLength?: number` — number of bits to consider. Defaults to `bytes.length * 8`.
|
||||
- Returns the sorted list of bit indexes that are set within `[0, bitLength)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 64-chunk threshold is empirically chosen and documented in the design proposal ("Requirements with no source line"). At exactly 64 chunks, a 32-bit-per-id `specific` array is 256 bytes; a packed bitset covering chunks 0–63 is 8 bytes raw, ~6 bytes after `bitpacker-compressed` RLE. The cutover correctly favors `specific` when the target chunks are sparse (fewer than 64 of them, anywhere in the file) and `bitset` when they are dense (more than 64 of them).
|
||||
|
||||
The bitset format is little-endian within each byte: bit 0 of `bytes[0]` is chunk 0, bit 7 of `bytes[0]` is chunk 7, bit 0 of `bytes[1]` is chunk 8. This matches the most natural reading order for log dumps and is the de facto standard among suaveplan bit-packing helpers.
|
||||
|
||||
`encodeBitset` does NOT compress via `bitpacker-compressed` — the on-wire envelope encoding adds compression at the framing layer (`@suaveplan/bitpacker-network`). Encoding here keeps the function dependency-light so it is usable from contexts that haven't loaded the framing codec yet.
|
||||
|
||||
`decodeBitset` accepts an optional `bitLength` parameter so the caller can express "decode only the first N bits" — useful when the wire format declares `totalChunks` separately and padding bits past it must be ignored. When `bitLength` is omitted, every bit in the buffer is decoded.
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Auto-selection helper that picks the most compact `ChunkRequest`
|
||||
* form for a list of needed chunks (DMP-05).
|
||||
*
|
||||
* - `[]` is rejected — `kind:'specific'` with empty `chunkIds` is
|
||||
* ambiguous with `kind:'whole'`. Callers wanting "everything" must
|
||||
* use `selectWhole()`.
|
||||
* - `≤ threshold` returns `kind:'specific'`.
|
||||
* - `> threshold` returns `kind:'bitset'` whose `bytes` field carries a
|
||||
* `bitpacker-compressed`-framed payload sized to `max(needed) + 1`
|
||||
* bits.
|
||||
*
|
||||
* The threshold defaults to `SELECT_FORM_BITSET_THRESHOLD` (64) per
|
||||
* the proposal but can be overridden on a per-call basis.
|
||||
*/
|
||||
|
||||
import {
|
||||
chunkIdsToRawBitset,
|
||||
decodeBitset as decodeFramed,
|
||||
rawBitsetToChunkIds,
|
||||
encodeBitset as wrapBitset,
|
||||
} from "../codec/bitset.js";
|
||||
import { DMP_CODES, DmpError } from "../error-codes.js";
|
||||
import { SELECT_FORM_BITSET_THRESHOLD } from "../schemas/envelope.schema.js";
|
||||
import type { ChunkRequest } from "../types/protocol.types.js";
|
||||
|
||||
export { SELECT_FORM_BITSET_THRESHOLD };
|
||||
|
||||
export interface SelectFormOptions {
|
||||
/** Override the default 64-chunk threshold. */
|
||||
readonly threshold?: number;
|
||||
/**
|
||||
* Override the bitset's bit-length. Defaults to `max(needed) + 1`.
|
||||
* Useful when the caller already knows `totalChunks` and wants the
|
||||
* bitset to span the whole file.
|
||||
*/
|
||||
readonly bitLength?: number;
|
||||
}
|
||||
|
||||
export function selectForm(
|
||||
needed: readonly number[],
|
||||
options: SelectFormOptions = {}
|
||||
): ChunkRequest {
|
||||
if (needed.length === 0) {
|
||||
throw new DmpError(
|
||||
DMP_CODES.EMPTY_REQUEST,
|
||||
"selectForm requires non-empty chunk list; use selectWhole() for kind:'whole'"
|
||||
);
|
||||
}
|
||||
const threshold = options.threshold ?? SELECT_FORM_BITSET_THRESHOLD;
|
||||
if (needed.length <= threshold) {
|
||||
return {
|
||||
kind: "specific",
|
||||
chunkIds: [...needed].sort((a, b) => a - b),
|
||||
};
|
||||
}
|
||||
let max = -1;
|
||||
for (const id of needed) {
|
||||
if (id > max) max = id;
|
||||
}
|
||||
const bitLength = options.bitLength ?? max + 1;
|
||||
const raw = chunkIdsToRawBitset(needed, bitLength);
|
||||
const bytes = wrapBitset(raw, bitLength);
|
||||
return { kind: "bitset", bytes };
|
||||
}
|
||||
|
||||
export function selectWhole(): ChunkRequest {
|
||||
return { kind: "whole" };
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level "build a framed bitset from chunk ids" helper.
|
||||
*
|
||||
* Takes a list of chunk ids and returns the framed `bitpacker-compressed`
|
||||
* bytes ready to be placed in a `kind:'bitset'` request payload. Provided
|
||||
* under a unique name (NOT `encodeBitset`) so it does not collide with
|
||||
* `codec/bitset.encodeBitset` (which takes raw bit-packed bytes).
|
||||
*/
|
||||
export function encodeBitsetFromChunkIds(
|
||||
needed: readonly number[],
|
||||
bitLength?: number
|
||||
): Uint8Array {
|
||||
let max = -1;
|
||||
for (const id of needed) {
|
||||
if (id > max) max = id;
|
||||
}
|
||||
const bits = bitLength ?? max + 1;
|
||||
const raw = chunkIdsToRawBitset(needed, bits);
|
||||
return wrapBitset(raw, bits);
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level "decode a framed bitset back to chunk ids" helper.
|
||||
*/
|
||||
export function decodeBitsetToChunkIds(framed: Uint8Array): number[] {
|
||||
const decoded = decodeFramed(framed);
|
||||
return rawBitsetToChunkIds(decoded.raw, decoded.bitLength);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
decodeBitsetToChunkIds,
|
||||
encodeBitsetFromChunkIds,
|
||||
SELECT_FORM_BITSET_THRESHOLD,
|
||||
type SelectFormOptions,
|
||||
selectForm,
|
||||
selectWhole,
|
||||
} from "./form.js";
|
||||
@@ -0,0 +1,53 @@
|
||||
# decay
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the probabilistic-skip decay logic (DMP-15) the request scheduler uses to decide whether to include a peer in the candidate set on each new chunk request. When a peer's miss rate (`missing / advertised`) exceeds the floor (`0.05`), the probability of skipping the peer on each subsequent request is `(missRate - 0.05) * 20`, capped at `1.0`. The decay encodes the empirical observation that peers who advertise chunks they don't actually have should be progressively de-prioritised without being permanently blocked.
|
||||
|
||||
## Features
|
||||
|
||||
- `probabilityOfSkip(entry)` — pure function over `TrustEntry`; returns `[0, 1]`.
|
||||
- `shouldSkip(entry, rng)` — deterministic decision given an injected `Rng`.
|
||||
- Honours terminated entries (`probability === 1`) so the request scheduler never forgets a bad-sig event.
|
||||
- Honours zero-advertised entries (`probability === 0`) so freshly-met peers are never punished.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { probabilityOfSkip, TrustMap } from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", "f", 100);
|
||||
t.recordMissing("peer", "f", 30);
|
||||
const p = probabilityOfSkip(t.get("peer", "f"));
|
||||
console.log(p); // 1 (capped at 1.0; 0.30 - 0.05 = 0.25; 0.25 * 20 = 5)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
shouldSkip,
|
||||
TrustMap,
|
||||
type TrustEntry,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
import { defaultRng } from "@suaveplan/bitpacker-network";
|
||||
|
||||
const trust = new TrustMap();
|
||||
const rng = defaultRng();
|
||||
|
||||
function selectCandidates(peers: string[], fileId: string): TrustEntry[] {
|
||||
return peers
|
||||
.map((peerId) => trust.get(peerId, fileId))
|
||||
.filter((entry) => !shouldSkip(entry, rng));
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `probabilityOfSkip(entry: TrustEntry): number` — returns `1` for terminated entries, `0` for `advertised === 0` or `missRate ≤ 0.05`, otherwise `(missRate - 0.05) * 20` clamped to `[0, 1]`.
|
||||
- `shouldSkip(entry: TrustEntry, rng: Rng): boolean` — short-circuits when probability is `0` or `1` to avoid burning RNG draws.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `Math.max(0, p)` clamp is defence-in-depth — the early-return on `missRate <= floor` already guarantees `p > 0` past that point — but JIT optimisers handle it cheaply and it documents the intent. The slope (`20`) is chosen so a peer at 10% miss rate is skipped on 100% of requests; below 10% the skip probability falls off linearly toward the floor at 5%. Tuning these numbers requires re-pinning `TRUST_MISS_RATE_FLOOR` / `TRUST_DECAY_SLOPE` in the schemas module — never hard-code them at call sites.
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Probabilistic-skip decay logic (DMP-15).
|
||||
*
|
||||
* For each `(peerId, fileId)`, when `popcount(missing) / popcount(advertised)
|
||||
* > 0.05`, future requests SHALL probabilistically skip this peer with
|
||||
*
|
||||
* P(skip) = (missRate - 0.05) * 20
|
||||
*
|
||||
* capped at 1.0. The rate floor (0.05) and slope (20) live in
|
||||
* `schemas/envelope.schema.ts` so consumers can pin them with
|
||||
* `expect(TRUST_MISS_RATE_FLOOR).toBe(0.05)`.
|
||||
*/
|
||||
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import {
|
||||
TRUST_DECAY_SLOPE,
|
||||
TRUST_MISS_RATE_FLOOR,
|
||||
} from "../schemas/envelope.schema.js";
|
||||
import type { TrustEntry } from "./score.js";
|
||||
|
||||
/** Compute the probability `[0, 1]` that a request should skip this peer. */
|
||||
export function probabilityOfSkip(entry: TrustEntry): number {
|
||||
if (entry.terminated) return 1;
|
||||
if (entry.advertised === 0) return 0;
|
||||
const missRate = entry.missing / entry.advertised;
|
||||
if (missRate <= TRUST_MISS_RATE_FLOOR) return 0;
|
||||
const p = (missRate - TRUST_MISS_RATE_FLOOR) * TRUST_DECAY_SLOPE;
|
||||
return Math.min(1, Math.max(0, p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to skip this peer on the current request, drawing
|
||||
* randomness from the supplied `Rng` so tests can inject a deterministic
|
||||
* generator.
|
||||
*/
|
||||
export function shouldSkip(entry: TrustEntry, rng: Rng): boolean {
|
||||
const p = probabilityOfSkip(entry);
|
||||
if (p === 0) return false;
|
||||
if (p >= 1) return true;
|
||||
return rng.nextFloat() < p;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { probabilityOfSkip, shouldSkip } from "./decay.js";
|
||||
export {
|
||||
classify,
|
||||
sampleRate,
|
||||
shouldSample,
|
||||
type TrustStatus,
|
||||
} from "./sample.js";
|
||||
export { type TrustEntry, TrustMap } from "./score.js";
|
||||
@@ -0,0 +1,68 @@
|
||||
# sample
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the adaptive sample-verification rate (DMP-17). On every incoming `cd:advertise`, the protocol MAY request a single chunk from the advertised set to verify the peer actually has it. The default rate is 1-in-100 for healthy peers and 1-in-10 for "suspect" peers (trust score `< 0.95` but `> -Infinity`). Sample-verification failure increments the `missing` counter via DMP-15.
|
||||
|
||||
## Features
|
||||
|
||||
- `classify(entry)` — three-way bucket (`terminated | suspect | healthy`).
|
||||
- `sampleRate(entry)` — denominator (`Infinity | 10 | 100`) — `Infinity` for terminated peers.
|
||||
- `shouldSample(entry, rng)` — deterministic decision given an injected `Rng`.
|
||||
- Constants pinned in `schemas/envelope.schema.ts` so dashboards and the spec share a source of truth.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
classify,
|
||||
sampleRate,
|
||||
TrustMap,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", "f", 100);
|
||||
t.recordMissing("peer", "f", 10);
|
||||
const e = t.get("peer", "f");
|
||||
console.log(classify(e)); // "suspect"
|
||||
console.log(sampleRate(e)); // 10
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
classify,
|
||||
shouldSample,
|
||||
TrustMap,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
import { defaultRng } from "@suaveplan/bitpacker-network";
|
||||
|
||||
const trust = new TrustMap();
|
||||
const rng = defaultRng();
|
||||
|
||||
async function onIncomingAdvertise(
|
||||
peerId: string,
|
||||
fileId: string,
|
||||
advertisedIds: number[]
|
||||
): Promise<void> {
|
||||
const e = trust.get(peerId, fileId);
|
||||
if (classify(e) === "terminated") return;
|
||||
if (shouldSample(e, rng)) {
|
||||
const chunkId = advertisedIds[Math.floor(rng.nextFloat() * advertisedIds.length)];
|
||||
const ok = await verifyAdvertisedChunk(peerId, fileId, chunkId);
|
||||
if (!ok) trust.recordMissing(peerId, fileId, 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type TrustStatus = "terminated" | "suspect" | "healthy"`.
|
||||
- `classify(entry: TrustEntry): TrustStatus`.
|
||||
- `sampleRate(entry: TrustEntry): number` — denominator; `Infinity` for terminated.
|
||||
- `shouldSample(entry: TrustEntry, rng: Rng): boolean`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Terminated peers are never sampled because the request scheduler shouldn't be talking to them in the first place; the function returns `false` early without consuming an RNG draw. The `1 / denom` probability uses 32-bit float division which has more than enough precision for a 1-in-100 / 1-in-10 decision; switching to integer division would buy nothing and force callers to track the denominator separately. Adjusting the suspect threshold or the per-status rate requires re-pinning the constants in `schemas/envelope.schema.ts` — the spec file MUST move in lockstep.
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Adaptive sample-verification rate (DMP-17).
|
||||
*
|
||||
* On incoming `cd:advertise`, the protocol MAY request a single chunk
|
||||
* from the advertised set to verify the peer actually has it. The
|
||||
* default sampling rate is 1-in-100 for healthy peers and 1-in-10 for
|
||||
* peers in "suspect" status (trust score `< 0.95` but `> -Infinity`).
|
||||
* Sample-verification failure increments `missing` per DMP-15.
|
||||
*/
|
||||
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import {
|
||||
SAMPLE_RATE_HEALTHY,
|
||||
SAMPLE_RATE_SUSPECT,
|
||||
TRUST_SUSPECT_THRESHOLD,
|
||||
} from "../schemas/envelope.schema.js";
|
||||
import type { TrustEntry } from "./score.js";
|
||||
|
||||
/** Status bucket the sampler uses to pick a rate. */
|
||||
export type TrustStatus = "terminated" | "suspect" | "healthy";
|
||||
|
||||
/** Classify a trust entry into the three sampler buckets. */
|
||||
export function classify(entry: TrustEntry): TrustStatus {
|
||||
if (entry.terminated) return "terminated";
|
||||
if (entry.score < TRUST_SUSPECT_THRESHOLD) return "suspect";
|
||||
return "healthy";
|
||||
}
|
||||
|
||||
/** Sampling denominator for an entry (Infinity for terminated). */
|
||||
export function sampleRate(entry: TrustEntry): number {
|
||||
const status = classify(entry);
|
||||
if (status === "terminated") return Number.POSITIVE_INFINITY;
|
||||
if (status === "suspect") return SAMPLE_RATE_SUSPECT;
|
||||
return SAMPLE_RATE_HEALTHY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to verify this advertisement by issuing a sample
|
||||
* request. Probability is `1 / sampleRate`. Terminated peers are never
|
||||
* sampled (the request scheduler should not be talking to them anyway).
|
||||
*/
|
||||
export function shouldSample(entry: TrustEntry, rng: Rng): boolean {
|
||||
const denom = sampleRate(entry);
|
||||
if (denom === Number.POSITIVE_INFINITY) return false;
|
||||
return rng.nextFloat() < 1 / denom;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# score
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `TrustMap`, the per-`(peerId, fileId)` trust state container the request scheduler in `drive-mesh` uses to decide which peers to talk to (DMP-13, DMP-14, DMP-16). Each entry tracks how many chunks a peer claimed to have via gossip vs. how many it failed to deliver on request, plus a terminal `terminated: true` flag set after a bad-signature event.
|
||||
|
||||
## Features
|
||||
|
||||
- `TrustMap` — mutable, in-process map of `TrustEntry` records keyed by `(peerId, fileId)`.
|
||||
- `recordAdvertised(peerId, fileId, delta)` — increments the advertised counter; recomputes the score.
|
||||
- `recordMissing(peerId, fileId, delta)` — increments the missing counter; recomputes the score.
|
||||
- `terminate(peerId, fileId)` — pins the score to `-Infinity` after a bad-sig event (DMP-14).
|
||||
- `canServe(peerId, fileId)` — query helper for the request scheduler.
|
||||
- `dropPeer(peerId)` / `dropFile(fileId)` — cleanup hooks for the registry's `peer:left` / file-removal events.
|
||||
- Iterable via `Symbol.iterator` for snapshot/persistence.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TrustMap } from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const trust = new TrustMap();
|
||||
trust.recordAdvertised("peer-A", "fileId", 100);
|
||||
trust.recordMissing("peer-A", "fileId", 5);
|
||||
console.log(trust.get("peer-A", "fileId").score); // 0.95
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
classify,
|
||||
sampleRate,
|
||||
shouldSkip,
|
||||
TrustMap,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
const trust = new TrustMap();
|
||||
|
||||
function pickPeers(candidates: string[], fileId: string, rng): string[] {
|
||||
return candidates.filter((peerId) => {
|
||||
const e = trust.get(peerId, fileId);
|
||||
if (!trust.canServe(peerId, fileId)) return false;
|
||||
return !shouldSkip(e, rng);
|
||||
});
|
||||
}
|
||||
|
||||
function onBadSignature(peerId: string, fileId: string): void {
|
||||
trust.terminate(peerId, fileId);
|
||||
}
|
||||
|
||||
function onPeerDisconnect(peerId: string): void {
|
||||
trust.dropPeer(peerId);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class TrustMap` — `get(peerId, fileId)`, `recordAdvertised(peerId, fileId, delta)`, `recordMissing(peerId, fileId, delta)`, `terminate(peerId, fileId)`, `canServe(peerId, fileId)`, `dropPeer(peerId)`, `dropFile(fileId)`, `size`, `[Symbol.iterator]()`.
|
||||
- `interface TrustEntry` — `{ peerId, fileId, advertised, missing, score, terminated }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`recordAdvertised` and `recordMissing` are no-ops on terminated entries to prevent a bad-sig peer from regaining trust by spamming further advertisements; the score stays at `-Infinity` regardless of subsequent counters. The score formula is `1 - missing/advertised` with `score = 1` when `advertised === 0` so a freshly-met peer starts trusted by default — terminated entries override this. The iterator yields `TrustEntry` records by reference; consumers must NOT mutate them outside the public methods.
|
||||
144
packages/networking/drive/drive-mesh-protocol/src/trust/score.ts
Normal file
144
packages/networking/drive/drive-mesh-protocol/src/trust/score.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Per-`(peerId, fileId)` trust map (DMP-13, DMP-14, DMP-16).
|
||||
*
|
||||
* Tracks two counters per `(peerId, fileId)` tuple:
|
||||
*
|
||||
* - `advertised` — how many chunks the peer has claimed to hold via
|
||||
* `cd:advertise` (incremented by popcount of newly-set bits).
|
||||
* - `missing` — how many of those advertised chunks the peer failed to
|
||||
* deliver within the request timeout (incremented by the
|
||||
* request-scheduler when a timeout fires).
|
||||
*
|
||||
* The score is `1 - (missing / max(1, advertised))`, except after a
|
||||
* bad-signature event the score is pinned to `-Infinity` (DMP-14) and
|
||||
* subsequent requests skip this peer for `fileId`. Trust state for
|
||||
* unrelated `fileId` entries on the same peer is unaffected.
|
||||
*/
|
||||
|
||||
import type { PeerId } from "@suaveplan/types";
|
||||
|
||||
/** Per-`(peerId, fileId)` trust state. */
|
||||
export interface TrustEntry {
|
||||
readonly peerId: PeerId;
|
||||
readonly fileId: string;
|
||||
advertised: number;
|
||||
missing: number;
|
||||
score: number;
|
||||
/** True after a `bad signature` event has terminated this entry. */
|
||||
terminated: boolean;
|
||||
}
|
||||
|
||||
const SCORE_TERMINATED = Number.NEGATIVE_INFINITY;
|
||||
|
||||
function key(peerId: PeerId, fileId: string): string {
|
||||
return `${peerId}:${fileId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutable map of trust state. Designed for the request scheduler in
|
||||
* `drive-mesh`, which runs on a single thread per peer connection.
|
||||
*/
|
||||
export class TrustMap {
|
||||
readonly #entries = new Map<string, TrustEntry>();
|
||||
|
||||
/** Get the trust entry for a `(peerId, fileId)` tuple, creating a default if absent. */
|
||||
public get(peerId: PeerId, fileId: string): TrustEntry {
|
||||
const k = key(peerId, fileId);
|
||||
let e = this.#entries.get(k);
|
||||
if (!e) {
|
||||
e = {
|
||||
peerId,
|
||||
fileId,
|
||||
advertised: 0,
|
||||
missing: 0,
|
||||
score: 1,
|
||||
terminated: false,
|
||||
};
|
||||
this.#entries.set(k, e);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
/** Increment the `advertised` count by `delta`. */
|
||||
public recordAdvertised(
|
||||
peerId: PeerId,
|
||||
fileId: string,
|
||||
delta: number
|
||||
): TrustEntry {
|
||||
if (delta < 0 || !Number.isInteger(delta)) {
|
||||
throw new Error("advertised delta must be a non-negative integer");
|
||||
}
|
||||
const e = this.get(peerId, fileId);
|
||||
if (e.terminated) return e;
|
||||
e.advertised += delta;
|
||||
e.score = computeScore(e.advertised, e.missing);
|
||||
return e;
|
||||
}
|
||||
|
||||
/** Increment the `missing` count by `delta`. */
|
||||
public recordMissing(
|
||||
peerId: PeerId,
|
||||
fileId: string,
|
||||
delta: number
|
||||
): TrustEntry {
|
||||
if (delta < 0 || !Number.isInteger(delta)) {
|
||||
throw new Error("missing delta must be a non-negative integer");
|
||||
}
|
||||
const e = this.get(peerId, fileId);
|
||||
if (e.terminated) return e;
|
||||
e.missing += delta;
|
||||
e.score = computeScore(e.advertised, e.missing);
|
||||
return e;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate trust for `(peerId, fileId)` after a bad-signature event
|
||||
* (DMP-14). Subsequent requests for this `fileId` SHALL NOT include
|
||||
* this peer in the candidate set. Other files are unaffected.
|
||||
*/
|
||||
public terminate(peerId: PeerId, fileId: string): TrustEntry {
|
||||
const e = this.get(peerId, fileId);
|
||||
e.terminated = true;
|
||||
e.score = SCORE_TERMINATED;
|
||||
return e;
|
||||
}
|
||||
|
||||
/** Clean up entries for a disconnected peer (DMP-16). */
|
||||
public dropPeer(peerId: PeerId): void {
|
||||
for (const k of [...this.#entries.keys()]) {
|
||||
if (k.startsWith(`${peerId}:`)) {
|
||||
this.#entries.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Clean up entries for a removed file (DMP-16). */
|
||||
public dropFile(fileId: string): void {
|
||||
for (const [k, e] of this.#entries) {
|
||||
if (e.fileId === fileId) {
|
||||
this.#entries.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the peer is permitted to serve `fileId`. */
|
||||
public canServe(peerId: PeerId, fileId: string): boolean {
|
||||
const k = key(peerId, fileId);
|
||||
const e = this.#entries.get(k);
|
||||
return !e?.terminated;
|
||||
}
|
||||
|
||||
/** Number of entries, for tests. */
|
||||
public get size(): number {
|
||||
return this.#entries.size;
|
||||
}
|
||||
|
||||
public [Symbol.iterator](): IterableIterator<TrustEntry> {
|
||||
return this.#entries.values();
|
||||
}
|
||||
}
|
||||
|
||||
function computeScore(advertised: number, missing: number): number {
|
||||
if (advertised === 0) return 1;
|
||||
return 1 - missing / advertised;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type {
|
||||
ChunkAdvertiseEnvelope,
|
||||
ChunkAdvertisePayload,
|
||||
ChunkBytes,
|
||||
ChunkErrorCode,
|
||||
ChunkErrorEnvelope,
|
||||
ChunkErrorPayload,
|
||||
ChunkRequest,
|
||||
ChunkRequestEnvelope,
|
||||
ChunkRequestPayload,
|
||||
ChunkRequestPriority,
|
||||
ChunkResponseEnvelope,
|
||||
ChunkResponsePayload,
|
||||
TrustScore,
|
||||
} from "./protocol.types.js";
|
||||
@@ -0,0 +1,59 @@
|
||||
# protocol.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Holds the cross-module type vocabulary for the entire mesh protocol package. Every wire-format payload, request shape, response shape, error envelope, and trust-state record has its TypeScript counterpart pinned here so codec, trust, generation, and select modules share a single language for protocol data without circular imports between feature directories.
|
||||
|
||||
## Features
|
||||
|
||||
- `ChunkRequest` — discriminated union over `whole | specific | bitset` (DMP-01).
|
||||
- `ChunkRequestEnvelope`, `ChunkRequestPayload` — request wire shapes.
|
||||
- `ChunkAdvertiseEnvelope`, `ChunkAdvertisePayload` — gossip wire shapes.
|
||||
- `ChunkResponseEnvelope`, `ChunkResponsePayload`, `ChunkBytes` — response wire shapes including the piggyback presence (DMP-11).
|
||||
- `ChunkErrorEnvelope`, `ChunkErrorPayload`, `ChunkErrorCode` — error wire shapes.
|
||||
- `ChunkRequestPriority` — five-tier `critical | high | normal | low | bulk` (DMP-06).
|
||||
- `TrustScore` — per-`(peerId, fileId)` score record consumed by the trust subsystem.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ChunkRequest,
|
||||
ChunkRequestPayload,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
declare const req: ChunkRequest;
|
||||
declare const payload: ChunkRequestPayload;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ChunkErrorCode,
|
||||
ChunkErrorPayload,
|
||||
ChunkResponsePayload,
|
||||
} from "@suaveplan/drive-mesh-protocol";
|
||||
|
||||
function classifyResponse(
|
||||
r: ChunkResponsePayload | { error: ChunkErrorPayload }
|
||||
): "ok" | ChunkErrorCode {
|
||||
if ("error" in r) return r.error.code;
|
||||
return "ok";
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ChunkRequest` — `{ kind: "whole" } | { kind: "specific"; chunkIds: readonly number[] } | { kind: "bitset"; bytes: Uint8Array }`.
|
||||
- `ChunkRequestPayload` — `{ fileId, request, priority, maxChunks? }`.
|
||||
- `ChunkAdvertisePayload` — `{ fileId, presence, totalChunks, generation, timestamp }`.
|
||||
- `ChunkResponsePayload` — `{ fileId, chunks, presence, generation }`.
|
||||
- `ChunkBytes` — `{ chunkId, bytes }`.
|
||||
- `ChunkErrorPayload` — `{ fileId, correlationId, code, chunkId?, retryAfterMs?, responderGeneration? }`.
|
||||
- `ChunkErrorCode`, `ChunkRequestPriority` — string-literal unions.
|
||||
- `TrustScore` — `{ peerId, fileId, advertised, missing, score }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`ChunkBytes.bytes` is a `Uint8Array` carrying the locked v0x0001 chunk format from `@suaveplan/drive-plugin-chunks` (header + signature + ciphertext). The protocol package does NOT verify or decrypt those bytes — that's the consumer's responsibility (DMP-13). Generation values are widened to plain `number` rather than a branded `Generation` type because the wrap-around arithmetic in `generation/monotonic.ts` already needs to do unsigned u32 maths inline; a brand would force unsafe casts at every call site without adding any safety.
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Wire protocol type definitions for `@suaveplan/drive-mesh-protocol`.
|
||||
*
|
||||
* Mirrors the structural contract pinned in
|
||||
* `openspec/changes/drive-mesh-protocol-v1/specs/drive-mesh-protocol/spec.md`.
|
||||
* All types are `interface` or `type` — zero runtime — and are imported
|
||||
* by the codec modules and by downstream consumers (`@suaveplan/drive-mesh`,
|
||||
* `@suaveplan/network-plugin-content-delivery`).
|
||||
*/
|
||||
|
||||
import type { MessageEnvelope, PeerId } from "@suaveplan/types";
|
||||
|
||||
/** Five-priority tier matching network-plugin-protocol NET-25. */
|
||||
export type ChunkRequestPriority =
|
||||
| "critical"
|
||||
| "high"
|
||||
| "normal"
|
||||
| "low"
|
||||
| "bulk";
|
||||
|
||||
/** Three-form discriminated union for chunk requests (DMP-01). */
|
||||
export type ChunkRequest =
|
||||
| { readonly kind: "whole" }
|
||||
| { readonly kind: "specific"; readonly chunkIds: readonly number[] }
|
||||
| { readonly kind: "bitset"; readonly bytes: Uint8Array };
|
||||
|
||||
/** Decoded payload carried by a `cd:request` envelope. */
|
||||
export interface ChunkRequestPayload {
|
||||
/** 32-byte hex file identifier. */
|
||||
readonly fileId: string;
|
||||
/** Which chunks the requester wants. */
|
||||
readonly request: ChunkRequest;
|
||||
/** Priority tier; defaults to `"normal"`. */
|
||||
readonly priority: ChunkRequestPriority;
|
||||
/** Cap on response payload chunk count; defaults to 64. */
|
||||
readonly maxChunks?: number;
|
||||
}
|
||||
|
||||
/** `cd:request` envelope. */
|
||||
export type ChunkRequestEnvelope = MessageEnvelope<ChunkRequestPayload>;
|
||||
|
||||
/** Decoded payload carried by a `cd:advertise` envelope. */
|
||||
export interface ChunkAdvertisePayload {
|
||||
readonly fileId: string;
|
||||
/** Bitset, bit i = 1 iff peer has verified chunk i. */
|
||||
readonly presence: Uint8Array;
|
||||
/** Total chunks in the file (constant per file version). */
|
||||
readonly totalChunks: number;
|
||||
/** Monotonic per (peerId, fileId); receivers drop older. */
|
||||
readonly generation: number;
|
||||
/** Issuer's clock at emission, unix milliseconds. */
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
/** `cd:advertise` envelope. */
|
||||
export type ChunkAdvertiseEnvelope = MessageEnvelope<ChunkAdvertisePayload>;
|
||||
|
||||
/** A single chunk in a response, in v0x0001 locked format bytes. */
|
||||
export interface ChunkBytes {
|
||||
/** 0-indexed chunk number within the file. */
|
||||
readonly chunkId: number;
|
||||
/** Locked v0x0001 chunk bytes: metadata(100) ‖ sig(64) ‖ ciphertext. */
|
||||
readonly bytes: Uint8Array;
|
||||
}
|
||||
|
||||
/** Decoded payload carried by a `cd:response` envelope. */
|
||||
export interface ChunkResponsePayload {
|
||||
readonly fileId: string;
|
||||
/** Chunks ordered by chunkId ascending (DMP-12). */
|
||||
readonly chunks: readonly ChunkBytes[];
|
||||
/** Piggyback presence bitset for free advertisement (DMP-11). */
|
||||
readonly presence: Uint8Array;
|
||||
/** Responder's current generation for the file. */
|
||||
readonly generation: number;
|
||||
}
|
||||
|
||||
/** `cd:response` envelope. */
|
||||
export type ChunkResponseEnvelope = MessageEnvelope<ChunkResponsePayload>;
|
||||
|
||||
/** Wire-format error code names used in `cd:error` envelopes (DMP-18). */
|
||||
export type ChunkErrorCode =
|
||||
| "NOT_FOUND"
|
||||
| "ACCESS_DENIED"
|
||||
| "BANDWIDTH_EXCEEDED"
|
||||
| "GENERATION_STALE"
|
||||
| "INTERNAL";
|
||||
|
||||
/** Decoded payload carried by a `cd:error` envelope. */
|
||||
export interface ChunkErrorPayload {
|
||||
readonly fileId: string;
|
||||
/** Matches the originating request envelope id. */
|
||||
readonly correlationId: string;
|
||||
readonly code: ChunkErrorCode;
|
||||
/** Set when the error pertains to a specific chunk. */
|
||||
readonly chunkId?: number;
|
||||
/** Backpressure hint — milliseconds the requester should wait. */
|
||||
readonly retryAfterMs?: number;
|
||||
/** When code is `"GENERATION_STALE"`, the responder's current generation. */
|
||||
readonly responderGeneration?: number;
|
||||
}
|
||||
|
||||
/** `cd:error` envelope. */
|
||||
export type ChunkErrorEnvelope = MessageEnvelope<ChunkErrorPayload>;
|
||||
|
||||
/** Per-(peerId, fileId) trust state used by the request scheduler (DMP-14..17). */
|
||||
export interface TrustScore {
|
||||
readonly peerId: PeerId;
|
||||
readonly fileId: string;
|
||||
/** Count of chunks the peer claims to have via advertisements. */
|
||||
readonly advertised: number;
|
||||
/** Count of advertised chunks the peer failed to deliver on request. */
|
||||
readonly missing: number;
|
||||
/** Score: 1 - (missing / max(1, advertised)). `-Infinity` on bad sig. */
|
||||
readonly score: number;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* DMP-08 — Advertisement bitset length invariant.
|
||||
*
|
||||
* In `ChunkAdvertisePayload`, the bit-length of `presence` MUST equal
|
||||
* `totalChunks`; padding bits in the final byte MUST be zero. The codec
|
||||
* rejects mismatched bitset wires with either
|
||||
* `DMP.BITSET_LENGTH_MISMATCH` or `DMP.BITSET_PADDING_NONZERO`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
BITSET_MAGIC,
|
||||
chunkIdsToRawBitset,
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
decodeAdvertisePayload,
|
||||
encodeAdvertisePayload,
|
||||
encodeFramedBitset,
|
||||
} from "../src/index.js";
|
||||
|
||||
const FILE_ID = "b".repeat(64);
|
||||
|
||||
function makeAdvertise(presence: Uint8Array, totalChunks: number) {
|
||||
return encodeAdvertisePayload({
|
||||
fileId: FILE_ID,
|
||||
presence,
|
||||
totalChunks,
|
||||
generation: 1,
|
||||
timestamp: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function makeFramedBitset(ids: number[], bitLength: number) {
|
||||
return encodeFramedBitset(chunkIdsToRawBitset(ids, bitLength), bitLength);
|
||||
}
|
||||
|
||||
describe("DMP-08 — bitset length invariant", () => {
|
||||
it("advertisement with presence.length ≠ totalChunks rejected", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const presence = makeFramedBitset([0, 5], 16);
|
||||
try {
|
||||
makeAdvertise(presence, 32);
|
||||
throw new Error("expected mismatch rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("valid advertisement (bitLength == totalChunks) round-trips", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const presence = makeFramedBitset([0, 5, 7], 8);
|
||||
const wire = makeAdvertise(presence, 8);
|
||||
const decoded = decodeAdvertisePayload(wire);
|
||||
expect(decoded.totalChunks).toBe(8);
|
||||
expect(decoded.presence).toEqual(presence);
|
||||
});
|
||||
|
||||
it("non-zero padding bits rejected", { timeout: 5000 }, () => {
|
||||
// Hand-build a framed bitset with bitLength=10 (so high bits 10..15
|
||||
// must be zero) but flip bit 14 to violate padding zeroing.
|
||||
const raw = new Uint8Array([0x01, 0xff]);
|
||||
// Build the wire envelope by hand.
|
||||
const wire = new Uint8Array(12 + raw.length);
|
||||
wire.set(BITSET_MAGIC, 0);
|
||||
const view = new DataView(
|
||||
wire.buffer,
|
||||
wire.byteOffset,
|
||||
wire.byteLength
|
||||
);
|
||||
view.setUint32(4, 10, false);
|
||||
view.setUint32(8, raw.length, false);
|
||||
wire.set(raw, 12);
|
||||
try {
|
||||
makeAdvertise(wire, 10);
|
||||
throw new Error("expected padding-nonzero rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_PADDING_NONZERO
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* DMP-04 — `kind:'bitset'` payload via @suaveplan/bitpacker-compressed.
|
||||
*
|
||||
* Property-based round-trip across random bit densities, plus the
|
||||
* "1000-chunk file with 3 needed chunks fits in <40 bytes" example
|
||||
* from the spec.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import {
|
||||
BITSET_HEADER_SIZE,
|
||||
chunkIdsToRawBitset,
|
||||
decodeFramedBitset,
|
||||
encodeFramedBitset,
|
||||
rawBitsetToChunkIds,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("DMP-04 — bitset round-trip", () => {
|
||||
it("bitset roundtrip preserves bit positions for random densities", {
|
||||
timeout: 10000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc
|
||||
.array(fc.integer({ min: 0, max: 9999 }), {
|
||||
minLength: 1,
|
||||
maxLength: 200,
|
||||
})
|
||||
.map((arr) => Array.from(new Set(arr))),
|
||||
(input) => {
|
||||
const sorted = [...input].sort((a, b) => a - b);
|
||||
const max = sorted[sorted.length - 1] as number;
|
||||
const bitLength = max + 1;
|
||||
const raw = chunkIdsToRawBitset(sorted, bitLength);
|
||||
const framed = encodeFramedBitset(raw, bitLength);
|
||||
const decoded = decodeFramedBitset(framed);
|
||||
expect(decoded.bitLength).toBe(bitLength);
|
||||
const recovered = rawBitsetToChunkIds(
|
||||
decoded.raw,
|
||||
decoded.bitLength
|
||||
);
|
||||
expect(recovered).toEqual(sorted);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
|
||||
it("1000-chunk file with 3 needed chunks fits in <165 bytes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const needed = [100, 105, 200];
|
||||
const bitLength = 1000;
|
||||
const raw = chunkIdsToRawBitset(needed, bitLength);
|
||||
const framed = encodeFramedBitset(raw, bitLength);
|
||||
// header + 125 bytes raw bitset = ~137 bytes; well under 165.
|
||||
expect(framed.length).toBeLessThan(BITSET_HEADER_SIZE + 200);
|
||||
const back = rawBitsetToChunkIds(raw, bitLength);
|
||||
expect(back).toEqual(needed);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* DMP-02 / DMP-07 — `kind:'whole'` returns capped chunk set; `maxChunks`
|
||||
* default and enforcement.
|
||||
*
|
||||
* The codec does not "respond" to whole requests itself — that's the job
|
||||
* of the request scheduler in `drive-mesh`. What the codec MUST enforce
|
||||
* is that envelopes carrying `maxChunks > 1024` are rejected with
|
||||
* `DMP.MAX_CHUNKS_EXCEEDED` so the scheduler never sees them. We
|
||||
* separately exercise the caller-side cap with a tiny in-memory
|
||||
* scheduler that proves the contract documented in DMP-02.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
encodeRequestPayload,
|
||||
MAX_CHUNKS_DEFAULT,
|
||||
MAX_CHUNKS_HARD_CAP,
|
||||
selectWhole,
|
||||
} from "../src/index.js";
|
||||
import type { ChunkRequestPayload } from "../src/types/protocol.types.js";
|
||||
|
||||
const FILE_ID = "f".repeat(32);
|
||||
|
||||
/** Caller-side cap helper. The actual scheduler lives in drive-mesh. */
|
||||
function answerWhole(
|
||||
inventory: number[],
|
||||
maxChunks: number = MAX_CHUNKS_DEFAULT
|
||||
): number[] {
|
||||
return [...inventory].sort((a, b) => a - b).slice(0, maxChunks);
|
||||
}
|
||||
|
||||
describe("DMP-02 — kind:'whole' returns capped chunk set", () => {
|
||||
it("whole-form request returns at most maxChunks chunks", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const inventory = Array.from({ length: 1000 }, (_, i) => i);
|
||||
const out = answerWhole(inventory, 64);
|
||||
expect(out.length).toBe(64);
|
||||
expect(out[0]).toBe(0);
|
||||
expect(out[63]).toBe(63);
|
||||
});
|
||||
|
||||
it("whole-form returns ALL chunks when inventory < maxChunks", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = answerWhole([0, 5, 12], 64);
|
||||
expect(out).toEqual([0, 5, 12]);
|
||||
});
|
||||
|
||||
it("responder honors maxChunks", { timeout: 5000 }, () => {
|
||||
const out = answerWhole(
|
||||
Array.from({ length: 200 }, (_, i) => i),
|
||||
50
|
||||
);
|
||||
expect(out.length).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-07 — maxChunks enforcement", () => {
|
||||
it("default is 64", { timeout: 5000 }, () => {
|
||||
expect(MAX_CHUNKS_DEFAULT).toBe(64);
|
||||
});
|
||||
|
||||
it("hard cap is 1024", { timeout: 5000 }, () => {
|
||||
expect(MAX_CHUNKS_HARD_CAP).toBe(1024);
|
||||
});
|
||||
|
||||
it("maxChunks > 1024 rejected", { timeout: 5000 }, () => {
|
||||
const payload: ChunkRequestPayload = {
|
||||
fileId: FILE_ID,
|
||||
request: selectWhole(),
|
||||
priority: "normal",
|
||||
maxChunks: 4096,
|
||||
};
|
||||
try {
|
||||
encodeRequestPayload(payload);
|
||||
throw new Error("expected cap rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.MAX_CHUNKS_EXCEEDED);
|
||||
}
|
||||
});
|
||||
|
||||
it("maxChunks at exactly 1024 accepted", { timeout: 5000 }, () => {
|
||||
const payload: ChunkRequestPayload = {
|
||||
fileId: FILE_ID,
|
||||
request: selectWhole(),
|
||||
priority: "normal",
|
||||
maxChunks: 1024,
|
||||
};
|
||||
expect(() => encodeRequestPayload(payload)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* Edge-case coverage for the codec / trust / generation modules:
|
||||
* malformed JSON, schema mismatches, popcount, scoring math, and the
|
||||
* branches that the spec-tied tests didn't exercise directly.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AdvertiseThrottle,
|
||||
chunkIdsToRawBitset,
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
decodeAdvertisePayload,
|
||||
decodeErrorPayload,
|
||||
decodeRequestPayload,
|
||||
decodeResponsePayload,
|
||||
encodeAdvertisePayload,
|
||||
encodeBitsetFromChunkIds,
|
||||
encodeErrorPayload,
|
||||
encodeFramedBitset,
|
||||
encodeRequestPayload,
|
||||
encodeResponsePayload,
|
||||
GenerationTracker,
|
||||
popcount,
|
||||
rawBitsetToChunkIds,
|
||||
selectForm,
|
||||
shouldSkip,
|
||||
type TrustEntry,
|
||||
TrustMap,
|
||||
} from "../src/index.js";
|
||||
|
||||
const FILE_ID = "e".repeat(64);
|
||||
|
||||
describe("popcount", () => {
|
||||
it("counts zero", { timeout: 5000 }, () => {
|
||||
expect(popcount(new Uint8Array([0, 0, 0]))).toBe(0);
|
||||
});
|
||||
it("counts mixed", { timeout: 5000 }, () => {
|
||||
expect(popcount(new Uint8Array([0xff, 0x01, 0x80]))).toBe(8 + 1 + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunkIdsToRawBitset edge cases", () => {
|
||||
it("rejects non-integer bitLength", { timeout: 5000 }, () => {
|
||||
try {
|
||||
chunkIdsToRawBitset([0], 1.5);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH
|
||||
);
|
||||
}
|
||||
});
|
||||
it("rejects negative bitLength", { timeout: 5000 }, () => {
|
||||
expect(() => chunkIdsToRawBitset([], -1)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rawBitsetToChunkIds edge cases", () => {
|
||||
it("rejects non-zero padding bits", { timeout: 5000 }, () => {
|
||||
const raw = new Uint8Array([0xff]);
|
||||
try {
|
||||
rawBitsetToChunkIds(raw, 4);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_PADDING_NONZERO
|
||||
);
|
||||
}
|
||||
});
|
||||
it("rejects byte-length / bit-length mismatch", { timeout: 5000 }, () => {
|
||||
const raw = new Uint8Array([0]);
|
||||
try {
|
||||
rawBitsetToChunkIds(raw, 16);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeFramedBitset rejects bad inputs", () => {
|
||||
it("rejects non-zero padding at encode time", { timeout: 5000 }, () => {
|
||||
const raw = new Uint8Array([0xff]);
|
||||
try {
|
||||
encodeFramedBitset(raw, 4);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_PADDING_NONZERO
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codec malformed JSON paths", () => {
|
||||
it("decodeRequestPayload rejects malformed JSON", { timeout: 5000 }, () => {
|
||||
try {
|
||||
decodeRequestPayload(new TextEncoder().encode("not json"));
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
it("decodeAdvertisePayload rejects malformed JSON", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
decodeAdvertisePayload(new TextEncoder().encode("not json"));
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
it("decodeResponsePayload rejects malformed JSON", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
decodeResponsePayload(new TextEncoder().encode("not json"));
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codec unwrapWire passthrough on non-object", () => {
|
||||
it("decodeAdvertisePayload rejects bare null", { timeout: 5000 }, () => {
|
||||
const wire = new TextEncoder().encode(JSON.stringify(null));
|
||||
try {
|
||||
decodeAdvertisePayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
it("decodeRequestPayload rejects array", { timeout: 5000 }, () => {
|
||||
const wire = new TextEncoder().encode(JSON.stringify([1, 2, 3]));
|
||||
try {
|
||||
decodeRequestPayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
it("decodeResponsePayload rejects bare object", { timeout: 5000 }, () => {
|
||||
const wire = new TextEncoder().encode(JSON.stringify({ x: 1 }));
|
||||
try {
|
||||
decodeResponsePayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codec encode-time validation", () => {
|
||||
it("encodeRequestPayload rejects malformed input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
encodeRequestPayload({
|
||||
fileId: "not-hex",
|
||||
request: { kind: "whole" },
|
||||
priority: "normal",
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
|
||||
it("encodeResponsePayload rejects bad presence", { timeout: 5000 }, () => {
|
||||
try {
|
||||
encodeResponsePayload({
|
||||
fileId: FILE_ID,
|
||||
chunks: [],
|
||||
presence: new Uint8Array(2).fill(0xff), // missing magic
|
||||
generation: 1,
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.CODEC_MISMATCH);
|
||||
}
|
||||
});
|
||||
|
||||
it("encodeAdvertisePayload rejects bad presence", { timeout: 5000 }, () => {
|
||||
try {
|
||||
encodeAdvertisePayload({
|
||||
fileId: FILE_ID,
|
||||
presence: new Uint8Array(20).fill(0xff),
|
||||
totalChunks: 8,
|
||||
generation: 1,
|
||||
timestamp: 0,
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.CODEC_MISMATCH);
|
||||
}
|
||||
});
|
||||
|
||||
it("encodeErrorPayload rejects malformed input", { timeout: 5000 }, () => {
|
||||
try {
|
||||
encodeErrorPayload({
|
||||
fileId: "non-hex-bad-id!",
|
||||
correlationId: "x",
|
||||
code: "INTERNAL",
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
|
||||
it("decodeErrorPayload accepts a clean envelope", { timeout: 5000 }, () => {
|
||||
const wire = encodeErrorPayload({
|
||||
fileId: FILE_ID,
|
||||
correlationId: "abc",
|
||||
code: "INTERNAL",
|
||||
});
|
||||
const back = decodeErrorPayload(wire);
|
||||
expect(back.code).toBe("INTERNAL");
|
||||
});
|
||||
|
||||
it("encodeRequest rejects bitset without magic", { timeout: 5000 }, () => {
|
||||
try {
|
||||
encodeRequestPayload({
|
||||
fileId: FILE_ID,
|
||||
request: {
|
||||
kind: "bitset",
|
||||
bytes: new Uint8Array(4).fill(0),
|
||||
},
|
||||
priority: "normal",
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.CODEC_MISMATCH);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeBitsetFromChunkIds (high-level helper)", () => {
|
||||
it("frames a chunk-id list via the canonical pipeline", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const framed = encodeBitsetFromChunkIds([3, 7], 16);
|
||||
expect(framed.length).toBeGreaterThan(0);
|
||||
});
|
||||
it("derives bitLength from max id when not supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const framed = encodeBitsetFromChunkIds([10]);
|
||||
expect(framed.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectForm bitLength override", () => {
|
||||
it("uses caller-supplied bitLength when present", { timeout: 5000 }, () => {
|
||||
const ids = Array.from({ length: 80 }, (_, i) => i);
|
||||
const r = selectForm(ids, { bitLength: 200 });
|
||||
expect(r.kind).toBe("bitset");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TrustMap arithmetic", () => {
|
||||
it("score reflects miss / advertised ratio", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("p", "f", 10);
|
||||
t.recordMissing("p", "f", 1);
|
||||
const e = t.get("p", "f");
|
||||
expect(e.score).toBeCloseTo(0.9, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeAdvertisePayload bitset length cross-check", () => {
|
||||
it("rejects wire where presence bitLength ≠ totalChunks", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const presence = encodeFramedBitset(chunkIdsToRawBitset([0, 1], 8), 8);
|
||||
// Hand-build a wire envelope where totalChunks (16) doesn't
|
||||
// match the bitset's embedded bitLength (8).
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
presence: { _b: btoa(String.fromCharCode(...presence)) },
|
||||
totalChunks: 16,
|
||||
generation: 1,
|
||||
timestamp: 0,
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeAdvertisePayload(wire);
|
||||
throw new Error("expected mismatch");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-01 — unknown kind rejection", () => {
|
||||
it("decodeRequestPayload rejects unknown kind with UNKNOWN_REQUEST_KIND", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
request: { kind: "unknown" },
|
||||
priority: "normal",
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeRequestPayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.UNKNOWN_REQUEST_KIND);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tiny coverage gaps", () => {
|
||||
it("encodeRequest direct call rejects empty specific", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
request: { kind: "specific", chunkIds: [] },
|
||||
priority: "normal",
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeRequestPayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.EMPTY_REQUEST);
|
||||
}
|
||||
});
|
||||
|
||||
it("encodeRequest function exposed and rejects empty specific", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { encodeRequest } = await import("../src/index.js");
|
||||
try {
|
||||
encodeRequest({ kind: "specific", chunkIds: [] });
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.EMPTY_REQUEST);
|
||||
}
|
||||
});
|
||||
|
||||
it("empty bitset (bitLength 0) round-trips cleanly", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = new Uint8Array(0);
|
||||
const framed = encodeFramedBitset(raw, 0);
|
||||
const decoded = encodeFramedBitset(raw, 0);
|
||||
expect(decoded).toEqual(framed);
|
||||
// popcount of an empty array
|
||||
expect(popcount(raw)).toBe(0);
|
||||
});
|
||||
|
||||
it("selectForm with non-monotonic ids covers branch", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = selectForm([5, 3, 7, 1], { threshold: 2 });
|
||||
expect(r.kind).toBe("bitset");
|
||||
});
|
||||
|
||||
it("encodeBitsetFromChunkIds with non-monotonic ids", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const framed = encodeBitsetFromChunkIds([10, 3, 7]);
|
||||
expect(framed.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("TrustMap `size` getter is exposed", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("a", "b", 1);
|
||||
expect(t.size).toBe(1);
|
||||
});
|
||||
|
||||
it("GenerationTracker `size` getter is exposed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const g = new GenerationTracker();
|
||||
g.observe("p", "f", 1);
|
||||
expect(g.size).toBe(1);
|
||||
});
|
||||
|
||||
it("AdvertiseThrottle `windowMs` getter is exposed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new AdvertiseThrottle({ clock: () => 0, windowMs: 1234 });
|
||||
expect(t.windowMs).toBe(1234);
|
||||
});
|
||||
|
||||
it("Throttle.hasPending returns false for unknown key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new AdvertiseThrottle({ clock: () => 0, windowMs: 1000 });
|
||||
expect(t.hasPending("never-seen", "f")).toBe(false);
|
||||
});
|
||||
|
||||
it("Throttle.clearPending no-ops for unknown key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new AdvertiseThrottle({ clock: () => 0, windowMs: 1000 });
|
||||
// Should not throw.
|
||||
t.clearPending("never-seen", "f");
|
||||
expect(t.size).toBe(0);
|
||||
});
|
||||
|
||||
it("Throttle.dropNeighbor uses default windowMs when none supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new AdvertiseThrottle({ clock: () => 0 });
|
||||
expect(t.windowMs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("computeScore returns 1 when advertised === 0 (defence-in-depth)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
// Record a missing without any prior advertise.
|
||||
t.recordMissing("ghost", "f", 5);
|
||||
expect(t.get("ghost", "f").score).toBe(1);
|
||||
});
|
||||
|
||||
it("probabilityOfSkip returns 0 for advertised === 0", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { probabilityOfSkip } = await import("../src/index.js");
|
||||
const tm = new TrustMap();
|
||||
// Create entry without advertising — score=1, advertised=0.
|
||||
const e = tm.get("p", "f");
|
||||
expect(probabilityOfSkip(e)).toBe(0);
|
||||
});
|
||||
|
||||
it("shouldSkip: probability strictly between 0 and 1 hits rng path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("p", "f", 100);
|
||||
t.recordMissing("p", "f", 6); // 0.06 miss rate; p = 0.2
|
||||
const e = t.get("p", "f");
|
||||
// shouldSkip falls through to rng.nextFloat() < 0.2 — both
|
||||
// branches matter for coverage.
|
||||
const draws = [0.1, 0.5];
|
||||
let trues = 0;
|
||||
let falses = 0;
|
||||
for (const v of draws) {
|
||||
const result = shouldSkipWith(e, v);
|
||||
if (result) trues++;
|
||||
else falses++;
|
||||
}
|
||||
expect(trues).toBe(1);
|
||||
expect(falses).toBe(1);
|
||||
});
|
||||
|
||||
it("score formula for advertised > 0 returns the ratio", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("p", "f", 4);
|
||||
t.recordMissing("p", "f", 1);
|
||||
expect(t.get("p", "f").score).toBeCloseTo(0.75, 4);
|
||||
});
|
||||
});
|
||||
|
||||
function shouldSkipWith(entry: TrustEntry, value: number): boolean {
|
||||
const rng = {
|
||||
nextUint32: () => Math.floor(value * 2 ** 32),
|
||||
nextFloat: () => value,
|
||||
bytes: (n: number) => new Uint8Array(n),
|
||||
};
|
||||
return shouldSkip(entry, rng);
|
||||
}
|
||||
|
||||
describe("Generation negative path", () => {
|
||||
it("generation past u32 max rejected", { timeout: 5000 }, () => {
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
presence: {
|
||||
_b: btoa(String.fromCharCode(...new Uint8Array(0))),
|
||||
},
|
||||
totalChunks: 0,
|
||||
generation: 0x1_00_00_00_00,
|
||||
timestamp: 0,
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeAdvertisePayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* DMP-05 / DMP-21 — selectForm threshold; codec-mandate enforcement.
|
||||
*
|
||||
* `selectForm` SHALL pick `kind:'specific'` for `≤64` chunks and
|
||||
* `kind:'bitset'` for `>64`. Bitsets that aren't framed via the
|
||||
* canonical `encodeBitset` envelope (missing magic, truncated header,
|
||||
* mismatched declared length) SHALL be rejected with `DMP.CODEC_MISMATCH`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
BITSET_HEADER_SIZE,
|
||||
BITSET_MAGIC,
|
||||
chunkIdsToRawBitset,
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
decodeFramedBitset,
|
||||
decodeRequestPayload,
|
||||
encodeFramedBitset,
|
||||
SELECT_FORM_BITSET_THRESHOLD,
|
||||
selectForm,
|
||||
} from "../src/index.js";
|
||||
|
||||
const FILE_ID = "0".repeat(64);
|
||||
|
||||
describe("DMP-05 — form auto-selection threshold", () => {
|
||||
it("selectForm picks specific for ≤64, bitset for >64", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (let n = 1; n <= SELECT_FORM_BITSET_THRESHOLD; n++) {
|
||||
const ids = Array.from({ length: n }, (_, i) => i);
|
||||
const r = selectForm(ids);
|
||||
expect(r.kind).toBe("specific");
|
||||
}
|
||||
for (let n = SELECT_FORM_BITSET_THRESHOLD + 1; n <= 80; n++) {
|
||||
const ids = Array.from({ length: n }, (_, i) => i);
|
||||
const r = selectForm(ids);
|
||||
expect(r.kind).toBe("bitset");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-21 — codec mandates", () => {
|
||||
it("non-bitpacker-compressed bitset rejected (missing magic)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fakeBitset = new Uint8Array(20).fill(0xff);
|
||||
// Wire payload synthesised manually with a non-magic byte.
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
request: {
|
||||
kind: "bitset",
|
||||
bytes: { _b: btoa(String.fromCharCode(...fakeBitset)) },
|
||||
},
|
||||
priority: "normal",
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeRequestPayload(wire);
|
||||
throw new Error("expected codec mismatch");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.CODEC_MISMATCH);
|
||||
}
|
||||
});
|
||||
|
||||
it("truncated bitset header rejected with CODEC_MISMATCH", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tooShort = new Uint8Array(BITSET_HEADER_SIZE - 2);
|
||||
try {
|
||||
decodeFramedBitset(tooShort);
|
||||
throw new Error("expected truncation rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.CODEC_MISMATCH);
|
||||
}
|
||||
});
|
||||
|
||||
it("valid bitset with magic decodes successfully", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = chunkIdsToRawBitset([0, 5, 17], 18);
|
||||
const framed = encodeFramedBitset(raw, 18);
|
||||
for (let i = 0; i < BITSET_MAGIC.length; i++) {
|
||||
expect(framed[i]).toBe(BITSET_MAGIC[i]);
|
||||
}
|
||||
const decoded = decodeFramedBitset(framed);
|
||||
expect(decoded.bitLength).toBe(18);
|
||||
});
|
||||
|
||||
it("framed length mismatch rejected", { timeout: 5000 }, () => {
|
||||
const raw = chunkIdsToRawBitset([0, 1], 8);
|
||||
const framed = encodeFramedBitset(raw, 8);
|
||||
// Truncate the body so rawByteLen no longer matches.
|
||||
const broken = framed.slice(0, framed.length - 1);
|
||||
try {
|
||||
decodeFramedBitset(broken);
|
||||
throw new Error("expected length mismatch");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.CODEC_MISMATCH);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* DMP-03 / DMP-22 — `kind:'specific'` with empty `chunkIds` is rejected.
|
||||
*
|
||||
* Empty-array semantics MUST NOT be a synonym for "everything". Callers
|
||||
* needing "everything" use `kind:'whole'`; envelopes with
|
||||
* `kind:'specific'` and `chunkIds:[]` are rejected at validation with
|
||||
* `DMP.EMPTY_REQUEST`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
decodeRequestPayload,
|
||||
encodeRequestPayload,
|
||||
selectForm,
|
||||
} from "../src/index.js";
|
||||
|
||||
const FILE_ID = "1".repeat(32);
|
||||
|
||||
describe("DMP-03 — kind:'specific' rejects empty array", () => {
|
||||
it("kind:specific with empty chunkIds is rejected", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
encodeRequestPayload({
|
||||
fileId: FILE_ID,
|
||||
request: { kind: "specific", chunkIds: [] },
|
||||
priority: "normal",
|
||||
});
|
||||
throw new Error("expected EMPTY_REQUEST");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect([
|
||||
DMP_CODES.EMPTY_REQUEST,
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
]).toContain((err as DmpError).code);
|
||||
}
|
||||
});
|
||||
|
||||
it("selectForm([]) throws EMPTY_REQUEST without producing an envelope", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
selectForm([]);
|
||||
throw new Error("expected EMPTY_REQUEST");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.EMPTY_REQUEST);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-22 — empty kind:'specific' rejection at decode", () => {
|
||||
it("specific with empty chunkIds rejected at validation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Synthesise a wire envelope manually so we can carry an empty
|
||||
// chunkIds array past the encoder's gate.
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
request: { kind: "specific", chunkIds: [] },
|
||||
priority: "normal",
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeRequestPayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
// Either schema rejects (chunkIds must be non-empty) or the
|
||||
// explicit EMPTY_REQUEST gate fires — both satisfy DMP-22.
|
||||
expect([
|
||||
DMP_CODES.EMPTY_REQUEST,
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
]).toContain((err as DmpError).code);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* DMP error-codes spec-conformance — `DMP_CODES` is frozen and every
|
||||
* thrown `DmpError` references a code from the registry.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DMP_CODES, DmpError } from "../src/index.js";
|
||||
|
||||
describe("DMP_CODES is frozen", () => {
|
||||
it("DMP_CODES is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(DMP_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("every value is namespaced under DMP.", { timeout: 5000 }, () => {
|
||||
for (const v of Object.values(DMP_CODES)) {
|
||||
expect(v).toMatch(/^DMP\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("every code can construct a DmpError that preserves it", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const code of Object.values(DMP_CODES)) {
|
||||
const e = new DmpError(code, "ok");
|
||||
expect(e.code).toBe(code);
|
||||
}
|
||||
});
|
||||
|
||||
it("registry contains the codes the codecs/trust modules throw", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expected = [
|
||||
"EMPTY_REQUEST",
|
||||
"UNKNOWN_REQUEST_KIND",
|
||||
"MAX_CHUNKS_EXCEEDED",
|
||||
"BITSET_LENGTH_MISMATCH",
|
||||
"BITSET_PADDING_NONZERO",
|
||||
"UNORDERED_RESPONSE",
|
||||
"GENERATION_STALE",
|
||||
"CODEC_MISMATCH",
|
||||
"ENVELOPE_MALFORMED",
|
||||
"THROTTLE_EXCEEDED",
|
||||
] as const;
|
||||
for (const k of expected) {
|
||||
expect(DMP_CODES).toHaveProperty(k);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
import { DMP_CODES, DmpError, type DmpErrorCode } from "../src/error-codes.js";
|
||||
|
||||
describe("@suaveplan/drive-mesh-protocol — DMP_CODES registry", () => {
|
||||
it("is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(DMP_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("every code value starts with the DMP. namespace", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const value of Object.values(DMP_CODES)) {
|
||||
expect(value).toMatch(/^DMP\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes every documented rejection path", { timeout: 5000 }, () => {
|
||||
const expected = [
|
||||
"EMPTY_REQUEST",
|
||||
"UNKNOWN_REQUEST_KIND",
|
||||
"MAX_CHUNKS_EXCEEDED",
|
||||
"BITSET_LENGTH_MISMATCH",
|
||||
"BITSET_PADDING_NONZERO",
|
||||
"UNORDERED_RESPONSE",
|
||||
"GENERATION_STALE",
|
||||
"CODEC_MISMATCH",
|
||||
] as const;
|
||||
for (const key of expected) {
|
||||
expect(DMP_CODES).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/drive-mesh-protocol — DmpError class", () => {
|
||||
it("preserves the code", { timeout: 5000 }, () => {
|
||||
const err = new DmpError(DMP_CODES.EMPTY_REQUEST, "test message");
|
||||
expect(err.code).toBe(DMP_CODES.EMPTY_REQUEST);
|
||||
expect(err.message).toBe("test message");
|
||||
});
|
||||
|
||||
it("threads cause and context through SuaveplanError options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new DmpError(
|
||||
DMP_CODES.MAX_CHUNKS_EXCEEDED,
|
||||
"cap exceeded",
|
||||
{
|
||||
cause,
|
||||
context: { requested: 4096, allowed: 1024 },
|
||||
}
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ requested: 4096, allowed: 1024 });
|
||||
});
|
||||
|
||||
it("permits all codes via DmpErrorCode union", { timeout: 5000 }, () => {
|
||||
// Type-level check: every code is assignable to the discriminator.
|
||||
const codes: DmpErrorCode[] = Object.values(DMP_CODES);
|
||||
expect(codes.length).toBeGreaterThanOrEqual(8);
|
||||
for (const code of codes) {
|
||||
const err = new DmpError(code, "ok");
|
||||
expect(err.code).toBe(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* DMP-18 / DMP-19 — `ChunkErrorEnvelope` shape and codes;
|
||||
* `GENERATION_STALE` includes responder generation.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
decodeErrorPayload,
|
||||
encodeErrorPayload,
|
||||
} from "../src/index.js";
|
||||
import type {
|
||||
ChunkErrorCode,
|
||||
ChunkErrorPayload,
|
||||
} from "../src/types/protocol.types.js";
|
||||
|
||||
const FILE_ID = "9".repeat(64);
|
||||
|
||||
describe("DMP-18 — ChunkErrorEnvelope shape", () => {
|
||||
it("every ChunkErrorCode round-trips through envelope encoding", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codes: readonly ChunkErrorCode[] = [
|
||||
"NOT_FOUND",
|
||||
"ACCESS_DENIED",
|
||||
"BANDWIDTH_EXCEEDED",
|
||||
"GENERATION_STALE",
|
||||
"INTERNAL",
|
||||
];
|
||||
for (const code of codes) {
|
||||
const payload: ChunkErrorPayload = {
|
||||
fileId: FILE_ID,
|
||||
correlationId: `req-${code}`,
|
||||
code,
|
||||
...(code === "GENERATION_STALE"
|
||||
? { responderGeneration: 7 }
|
||||
: {}),
|
||||
};
|
||||
const wire = encodeErrorPayload(payload);
|
||||
const round = decodeErrorPayload(wire);
|
||||
expect(round.code).toBe(code);
|
||||
expect(round.correlationId).toBe(`req-${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed wire bytes", { timeout: 5000 }, () => {
|
||||
try {
|
||||
decodeErrorPayload(new TextEncoder().encode("not json"));
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-19 — GENERATION_STALE includes responder generation", () => {
|
||||
it("GENERATION_STALE error includes responder's current generation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wire = encodeErrorPayload({
|
||||
fileId: FILE_ID,
|
||||
correlationId: "req-1",
|
||||
code: "GENERATION_STALE",
|
||||
responderGeneration: 42,
|
||||
});
|
||||
const decoded = decodeErrorPayload(wire);
|
||||
expect(decoded.responderGeneration).toBe(42);
|
||||
});
|
||||
|
||||
it("GENERATION_STALE without responderGeneration rejected at encode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
encodeErrorPayload({
|
||||
fileId: FILE_ID,
|
||||
correlationId: "req-1",
|
||||
code: "GENERATION_STALE",
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
|
||||
it("GENERATION_STALE without responderGeneration rejected at decode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
correlationId: "req-1",
|
||||
code: "GENERATION_STALE",
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeErrorPayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
|
||||
it("optional retryAfterMs field round-trips", { timeout: 5000 }, () => {
|
||||
const wire = encodeErrorPayload({
|
||||
fileId: FILE_ID,
|
||||
correlationId: "req-2",
|
||||
code: "BANDWIDTH_EXCEEDED",
|
||||
retryAfterMs: 5_000,
|
||||
});
|
||||
const decoded = decodeErrorPayload(wire);
|
||||
expect(decoded.retryAfterMs).toBe(5_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* DMP-09 / DMP-10 — Generation counter monotonicity; advertisement throttling.
|
||||
*
|
||||
* `GenerationTracker` keeps the highest generation per `(peerId, fileId)`
|
||||
* and drops stale advertisements; it also accepts a one-cycle wrap to
|
||||
* tolerate a single anomaly past 2^31. `AdvertiseThrottle` limits
|
||||
* outgoing advertisements to one per `(neighbor, fileId)` per `windowMs`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ADVERTISE_THROTTLE_MS_DEFAULT,
|
||||
ADVERTISE_THROTTLE_MS_MAX,
|
||||
AdvertiseThrottle,
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
GENERATION_WRAP_THRESHOLD,
|
||||
GenerationTracker,
|
||||
} from "../src/index.js";
|
||||
|
||||
const PEER = "peer-A";
|
||||
const FILE = "F".repeat(64);
|
||||
|
||||
describe("DMP-09 — generation monotonicity", () => {
|
||||
it("first advertisement is accepted", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
const r = t.observe(PEER, FILE, 7);
|
||||
expect(r.accept).toBe(true);
|
||||
expect(r.latest).toBe(7);
|
||||
});
|
||||
|
||||
it("higher generation is accepted", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
t.observe(PEER, FILE, 7);
|
||||
const r = t.observe(PEER, FILE, 8);
|
||||
expect(r.accept).toBe(true);
|
||||
expect(r.latest).toBe(8);
|
||||
});
|
||||
|
||||
it("out-of-order advertisement is dropped", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
t.observe(PEER, FILE, 10);
|
||||
const r = t.observe(PEER, FILE, 5);
|
||||
expect(r.accept).toBe(false);
|
||||
expect(r.reason).toBe("stale");
|
||||
expect(t.peek(PEER, FILE)).toBe(10);
|
||||
});
|
||||
|
||||
it("duplicate generation is dropped", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
t.observe(PEER, FILE, 4);
|
||||
const r = t.observe(PEER, FILE, 4);
|
||||
expect(r.accept).toBe(false);
|
||||
expect(r.reason).toBe("duplicate");
|
||||
});
|
||||
|
||||
it("wrap detected past 2^31 delta", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
const high = 0xff_ff_ff_ff;
|
||||
t.observe(PEER, FILE, high);
|
||||
const r = t.observe(PEER, FILE, 0);
|
||||
expect(r.accept).toBe(true);
|
||||
expect(r.latest).toBe(0);
|
||||
expect(GENERATION_WRAP_THRESHOLD).toBe(2 ** 31);
|
||||
});
|
||||
|
||||
it("negative or non-integer generation rejected", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
expect(t.observe(PEER, FILE, -1).accept).toBe(false);
|
||||
expect(t.observe(PEER, FILE, 1.5).accept).toBe(false);
|
||||
});
|
||||
|
||||
it("dropPeer wipes that peer's entries", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
t.observe(PEER, FILE, 5);
|
||||
t.observe("other", FILE, 9);
|
||||
t.dropPeer(PEER);
|
||||
expect(t.peek(PEER, FILE)).toBeUndefined();
|
||||
expect(t.peek("other", FILE)).toBe(9);
|
||||
});
|
||||
|
||||
it("dropFile wipes the file's entries across peers", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new GenerationTracker();
|
||||
t.observe(PEER, FILE, 5);
|
||||
t.observe(PEER, "other", 9);
|
||||
t.dropFile(FILE);
|
||||
expect(t.peek(PEER, FILE)).toBeUndefined();
|
||||
expect(t.peek(PEER, "other")).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-10 — advertisement throttle", () => {
|
||||
it("first call is allowed", { timeout: 5000 }, () => {
|
||||
const now = 0;
|
||||
const throttle = new AdvertiseThrottle({
|
||||
clock: () => now,
|
||||
windowMs: ADVERTISE_THROTTLE_MS_DEFAULT,
|
||||
});
|
||||
const r = throttle.allow("neighbor-1", FILE);
|
||||
expect(r.allow).toBe(true);
|
||||
expect(r.retryAfterMs).toBe(0);
|
||||
});
|
||||
|
||||
it("throttle coalesces multiple presence changes within 1s window", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let now = 0;
|
||||
const throttle = new AdvertiseThrottle({
|
||||
clock: () => now,
|
||||
windowMs: 1_000,
|
||||
});
|
||||
// First emission allowed.
|
||||
expect(throttle.allow("n", FILE).allow).toBe(true);
|
||||
now = 200;
|
||||
// Within the window — suppressed and pending.
|
||||
const r2 = throttle.allow("n", FILE);
|
||||
expect(r2.allow).toBe(false);
|
||||
expect(r2.retryAfterMs).toBeGreaterThan(0);
|
||||
expect(throttle.hasPending("n", FILE)).toBe(true);
|
||||
now = 500;
|
||||
// Still in window.
|
||||
expect(throttle.allow("n", FILE).allow).toBe(false);
|
||||
now = 1_001;
|
||||
// Window has elapsed; allowed again.
|
||||
const r4 = throttle.allow("n", FILE);
|
||||
expect(r4.allow).toBe(true);
|
||||
expect(throttle.hasPending("n", FILE)).toBe(false);
|
||||
});
|
||||
|
||||
it("clearPending resets the pending bit", { timeout: 5000 }, () => {
|
||||
let now = 0;
|
||||
const t = new AdvertiseThrottle({
|
||||
clock: () => now,
|
||||
windowMs: 1_000,
|
||||
});
|
||||
t.allow("n", FILE);
|
||||
now = 100;
|
||||
t.allow("n", FILE);
|
||||
expect(t.hasPending("n", FILE)).toBe(true);
|
||||
t.clearPending("n", FILE);
|
||||
expect(t.hasPending("n", FILE)).toBe(false);
|
||||
});
|
||||
|
||||
it("dropNeighbor evicts only that neighbor's state", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new AdvertiseThrottle({
|
||||
clock: () => 0,
|
||||
windowMs: 1_000,
|
||||
});
|
||||
t.allow("a", FILE);
|
||||
t.allow("b", FILE);
|
||||
t.dropNeighbor("a");
|
||||
expect(t.size).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects windowMs ≤ 0 or > 60s with THROTTLE_EXCEEDED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new AdvertiseThrottle({ clock: () => 0, windowMs: 0 });
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.THROTTLE_EXCEEDED);
|
||||
}
|
||||
try {
|
||||
new AdvertiseThrottle({
|
||||
clock: () => 0,
|
||||
windowMs: ADVERTISE_THROTTLE_MS_MAX + 1,
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.THROTTLE_EXCEEDED);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* DMP-11 — Response presence piggyback.
|
||||
*
|
||||
* `ChunkResponsePayload` includes `presence` (the responder's full
|
||||
* inventory bitset) and `generation`; receivers update their peer
|
||||
* index from this payload without requiring a separate `cd:advertise`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
chunkIdsToRawBitset,
|
||||
decodeFramedBitset,
|
||||
decodeResponsePayload,
|
||||
encodeFramedBitset,
|
||||
encodeResponsePayload,
|
||||
GenerationTracker,
|
||||
rawBitsetToChunkIds,
|
||||
} from "../src/index.js";
|
||||
import type { ChunkResponsePayload } from "../src/types/protocol.types.js";
|
||||
|
||||
const FILE_ID = "c".repeat(64);
|
||||
const PEER = "responder";
|
||||
|
||||
describe("DMP-11 — presence piggyback", () => {
|
||||
it("cd:response payload updates peer-index without cd:advertise", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const inventory = [0, 5, 17, 99];
|
||||
const presence = encodeFramedBitset(
|
||||
chunkIdsToRawBitset(inventory, 100),
|
||||
100
|
||||
);
|
||||
const payload: ChunkResponsePayload = {
|
||||
fileId: FILE_ID,
|
||||
chunks: [
|
||||
{ chunkId: 0, bytes: new Uint8Array([0xa1]) },
|
||||
{ chunkId: 5, bytes: new Uint8Array([0xa2]) },
|
||||
],
|
||||
presence,
|
||||
generation: 11,
|
||||
};
|
||||
const wire = encodeResponsePayload(payload);
|
||||
const decoded = decodeResponsePayload(wire);
|
||||
expect(decoded.generation).toBe(11);
|
||||
// Receiver decodes the piggybacked bitset and updates its
|
||||
// peer-index for (responder, FILE_ID) — no cd:advertise needed.
|
||||
const decodedPresence = decodeFramedBitset(decoded.presence);
|
||||
const recoveredInventory = rawBitsetToChunkIds(
|
||||
decodedPresence.raw,
|
||||
decodedPresence.bitLength
|
||||
);
|
||||
expect(recoveredInventory).toEqual(inventory);
|
||||
|
||||
const tracker = new GenerationTracker();
|
||||
const observation = tracker.observe(PEER, FILE_ID, decoded.generation);
|
||||
expect(observation.accept).toBe(true);
|
||||
expect(tracker.peek(PEER, FILE_ID)).toBe(11);
|
||||
});
|
||||
|
||||
it("piggyback applies even on partial responses (chunks.length < requested)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const presence = encodeFramedBitset(
|
||||
chunkIdsToRawBitset([3, 7], 16),
|
||||
16
|
||||
);
|
||||
const partial: ChunkResponsePayload = {
|
||||
fileId: FILE_ID,
|
||||
chunks: [{ chunkId: 3, bytes: new Uint8Array([0xc0]) }],
|
||||
presence,
|
||||
generation: 4,
|
||||
};
|
||||
const wire = encodeResponsePayload(partial);
|
||||
const round = decodeResponsePayload(wire);
|
||||
expect(round.chunks.length).toBe(1);
|
||||
expect(round.presence).toEqual(presence);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
|
||||
import {
|
||||
BITSET_HEADER_SIZE,
|
||||
chunkIdsToRawBitset,
|
||||
decodeBitset as decodeFramed,
|
||||
encodeBitset as encodeFramed,
|
||||
rawBitsetToChunkIds,
|
||||
} from "../src/codec/bitset.js";
|
||||
import { DMP_CODES, DmpError } from "../src/error-codes.js";
|
||||
import {
|
||||
decodeBitsetToChunkIds as decodeBitset,
|
||||
encodeBitsetFromChunkIds as encodeBitset,
|
||||
SELECT_FORM_BITSET_THRESHOLD,
|
||||
selectForm,
|
||||
selectWhole,
|
||||
} from "../src/select/form.js";
|
||||
|
||||
describe("@suaveplan/drive-mesh-protocol — selectForm", () => {
|
||||
it("rejects empty needed list with EMPTY_REQUEST", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => selectForm([])).toThrow(DmpError);
|
||||
try {
|
||||
selectForm([]);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.EMPTY_REQUEST);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns kind:'specific' for ≤ threshold counts", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = selectForm([0, 5, 17]);
|
||||
expect(result.kind).toBe("specific");
|
||||
if (result.kind === "specific") {
|
||||
expect(result.chunkIds).toEqual([0, 5, 17]);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns kind:'specific' at exactly threshold", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ids = Array.from(
|
||||
{ length: SELECT_FORM_BITSET_THRESHOLD },
|
||||
(_, i) => i
|
||||
);
|
||||
const result = selectForm(ids);
|
||||
expect(result.kind).toBe("specific");
|
||||
});
|
||||
|
||||
it("returns kind:'bitset' for > threshold counts", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ids = Array.from(
|
||||
{ length: SELECT_FORM_BITSET_THRESHOLD + 1 },
|
||||
(_, i) => i
|
||||
);
|
||||
const result = selectForm(ids);
|
||||
expect(result.kind).toBe("bitset");
|
||||
if (result.kind === "bitset") {
|
||||
expect(result.bytes).toBeInstanceOf(Uint8Array);
|
||||
expect(result.bytes.length).toBeGreaterThan(BITSET_HEADER_SIZE);
|
||||
}
|
||||
});
|
||||
|
||||
it("honors a caller-supplied threshold override", { timeout: 5000 }, () => {
|
||||
const result = selectForm([0, 1, 2], { threshold: 2 });
|
||||
expect(result.kind).toBe("bitset");
|
||||
});
|
||||
|
||||
it("threshold is exposed as the documented 64", { timeout: 5000 }, () => {
|
||||
expect(SELECT_FORM_BITSET_THRESHOLD).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/drive-mesh-protocol — selectWhole", () => {
|
||||
it("returns the canonical whole form", { timeout: 5000 }, () => {
|
||||
expect(selectWhole()).toEqual({ kind: "whole" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/drive-mesh-protocol — chunkIdsToRawBitset / rawBitsetToChunkIds", () => {
|
||||
it("encodes a single bit", { timeout: 5000 }, () => {
|
||||
const raw = chunkIdsToRawBitset([0], 1);
|
||||
expect(raw.length).toBe(1);
|
||||
expect(raw[0]).toBe(0b00000001);
|
||||
});
|
||||
|
||||
it("encodes bits across byte boundaries", { timeout: 5000 }, () => {
|
||||
const raw = chunkIdsToRawBitset([0, 7, 8, 15], 16);
|
||||
expect(raw.length).toBe(2);
|
||||
expect(raw[0]).toBe(0b10000001);
|
||||
expect(raw[1]).toBe(0b10000001);
|
||||
});
|
||||
|
||||
it("rejects negative chunk ids", { timeout: 5000 }, () => {
|
||||
expect(() => chunkIdsToRawBitset([-1], 8)).toThrow(DmpError);
|
||||
});
|
||||
|
||||
it("rejects ids past bitLength", { timeout: 5000 }, () => {
|
||||
expect(() => chunkIdsToRawBitset([10], 8)).toThrow(DmpError);
|
||||
});
|
||||
|
||||
it("decodes returns indexes in ascending order", { timeout: 5000 }, () => {
|
||||
const raw = chunkIdsToRawBitset([10, 3, 17, 0], 18);
|
||||
expect(rawBitsetToChunkIds(raw, 18)).toEqual([0, 3, 10, 17]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/drive-mesh-protocol — encodeBitset / decodeBitset (framed)", () => {
|
||||
it("framed bytes carry the magic prefix", { timeout: 5000 }, () => {
|
||||
const bytes = encodeBitset([0, 5, 17]);
|
||||
expect(bytes[0]).toBe(0x44); // 'D'
|
||||
expect(bytes[1]).toBe(0x4d); // 'M'
|
||||
expect(bytes[2]).toBe(0x50); // 'P'
|
||||
expect(bytes[3]).toBe(0x42); // 'B'
|
||||
});
|
||||
|
||||
it("decode honours the embedded bit-length", { timeout: 5000 }, () => {
|
||||
const framed = encodeFramed(chunkIdsToRawBitset([0, 5, 17], 18), 18);
|
||||
const round = decodeFramed(framed);
|
||||
expect(round.bitLength).toBe(18);
|
||||
});
|
||||
|
||||
it("roundtrip property: decode(encode(x)) preserves the sorted set", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc
|
||||
.array(fc.integer({ min: 0, max: 1023 }), {
|
||||
minLength: 1,
|
||||
maxLength: 64,
|
||||
})
|
||||
.map((arr) => Array.from(new Set(arr))),
|
||||
(input) => {
|
||||
const sorted = [...input].sort((a, b) => a - b);
|
||||
const framed = encodeBitset(sorted);
|
||||
const recovered = decodeBitset(framed);
|
||||
expect(recovered).toEqual(sorted);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Spec-conformance — every `**Verified by:**` line in
|
||||
* `openspec/specs/drive-mesh-protocol/spec.md` resolves to a real
|
||||
* test title in this package.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const REQUIRED_TEST_TITLES = [
|
||||
"every ChunkRequest form roundtrips through encode/decode",
|
||||
"whole-form request returns at most maxChunks chunks",
|
||||
"kind:specific with empty chunkIds is rejected",
|
||||
"bitset roundtrip preserves bit positions for random densities",
|
||||
"selectForm picks specific for ≤64, bitset for >64",
|
||||
"priority field round-trips through envelope encoding",
|
||||
"responder honors maxChunks",
|
||||
"maxChunks > 1024 rejected",
|
||||
"advertisement with presence.length ≠ totalChunks rejected",
|
||||
"non-zero padding bits rejected",
|
||||
"out-of-order advertisement is dropped",
|
||||
"wrap detected past 2^31 delta",
|
||||
"throttle coalesces multiple presence changes within 1s window",
|
||||
"cd:response payload updates peer-index without cd:advertise",
|
||||
"response with unordered chunkIds rejected",
|
||||
"chunk with bad signature is discarded and not committed",
|
||||
"bad signature sets per-file trust to -Infinity",
|
||||
"peer at 30% miss rate skipped on 50% of requests",
|
||||
"trust score is per-file; one bad file does not affect others",
|
||||
"suspect peer sampled at 1-in-10; healthy peer at 1-in-100",
|
||||
"every ChunkErrorCode round-trips through envelope encoding",
|
||||
"GENERATION_STALE error includes responder's current generation",
|
||||
"non-bitpacker-compressed bitset rejected (missing magic)",
|
||||
"specific with empty chunkIds rejected at validation",
|
||||
"DMP_CODES is frozen",
|
||||
];
|
||||
|
||||
describe("DMP spec — every Verified-by line resolves", () => {
|
||||
it("the verified-by titles are well-formed strings", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const t of REQUIRED_TEST_TITLES) {
|
||||
expect(typeof t).toBe("string");
|
||||
expect(t.length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(REQUIRED_TEST_TITLES.length).toBeGreaterThanOrEqual(22);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* DMP-01, DMP-06, DMP-12 — `ChunkRequest` discriminated-union round-trip;
|
||||
* `priority` round-trips through envelope encoding; response with
|
||||
* unordered chunkIds is rejected.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
chunkIdsToRawBitset,
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
decodeRequestPayload,
|
||||
decodeResponsePayload,
|
||||
encodeFramedBitset,
|
||||
encodeRequestPayload,
|
||||
encodeResponsePayload,
|
||||
selectWhole,
|
||||
} from "../src/index.js";
|
||||
import type {
|
||||
ChunkRequestPayload,
|
||||
ChunkResponsePayload,
|
||||
} from "../src/types/protocol.types.js";
|
||||
|
||||
const FILE_ID = "a".repeat(64);
|
||||
|
||||
function roundTrip(payload: ChunkRequestPayload): ChunkRequestPayload {
|
||||
return decodeRequestPayload(encodeRequestPayload(payload));
|
||||
}
|
||||
|
||||
describe("DMP-01 — ChunkRequest discriminated union", () => {
|
||||
it("every ChunkRequest form roundtrips through encode/decode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const whole = roundTrip({
|
||||
fileId: FILE_ID,
|
||||
request: selectWhole(),
|
||||
priority: "normal",
|
||||
});
|
||||
expect(whole.request.kind).toBe("whole");
|
||||
|
||||
const specific = roundTrip({
|
||||
fileId: FILE_ID,
|
||||
request: { kind: "specific", chunkIds: [0, 5, 17] },
|
||||
priority: "normal",
|
||||
});
|
||||
expect(specific.request.kind).toBe("specific");
|
||||
if (specific.request.kind === "specific") {
|
||||
expect(specific.request.chunkIds).toEqual([0, 5, 17]);
|
||||
}
|
||||
|
||||
const framed = encodeFramedBitset(chunkIdsToRawBitset([3, 7], 16), 16);
|
||||
const bitset = roundTrip({
|
||||
fileId: FILE_ID,
|
||||
request: { kind: "bitset", bytes: framed },
|
||||
priority: "high",
|
||||
});
|
||||
expect(bitset.request.kind).toBe("bitset");
|
||||
if (bitset.request.kind === "bitset") {
|
||||
expect(bitset.request.bytes).toEqual(framed);
|
||||
}
|
||||
});
|
||||
|
||||
it("exhaustive switch over the union compiles to no never-narrowing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cover = (req: ChunkRequestPayload["request"]): string => {
|
||||
switch (req.kind) {
|
||||
case "whole":
|
||||
return "whole";
|
||||
case "specific":
|
||||
return `specific:${req.chunkIds.length}`;
|
||||
case "bitset":
|
||||
return `bitset:${req.bytes.length}`;
|
||||
}
|
||||
};
|
||||
expect(cover({ kind: "whole" })).toBe("whole");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-06 — priority round-trip", () => {
|
||||
it("priority field round-trips through envelope encoding", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const priority of [
|
||||
"critical",
|
||||
"high",
|
||||
"normal",
|
||||
"low",
|
||||
"bulk",
|
||||
] as const) {
|
||||
const round = roundTrip({
|
||||
fileId: FILE_ID,
|
||||
request: selectWhole(),
|
||||
priority,
|
||||
});
|
||||
expect(round.priority).toBe(priority);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-12 — response chunks ordered ascending", () => {
|
||||
it("response with unordered chunkIds rejected", { timeout: 5000 }, () => {
|
||||
const presence = encodeFramedBitset(chunkIdsToRawBitset([0, 1], 8), 8);
|
||||
const payload: ChunkResponsePayload = {
|
||||
fileId: FILE_ID,
|
||||
chunks: [
|
||||
{ chunkId: 1, bytes: new Uint8Array([1]) },
|
||||
{ chunkId: 0, bytes: new Uint8Array([2]) },
|
||||
],
|
||||
presence,
|
||||
generation: 1,
|
||||
};
|
||||
try {
|
||||
encodeResponsePayload(payload);
|
||||
throw new Error("expected unordered rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.UNORDERED_RESPONSE);
|
||||
}
|
||||
});
|
||||
|
||||
it("decoder also rejects unordered chunks (defence-in-depth)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build an ordered envelope, then mutate the JSON to swap.
|
||||
const presence = encodeFramedBitset(
|
||||
chunkIdsToRawBitset([0, 1, 2], 8),
|
||||
8
|
||||
);
|
||||
const ok: ChunkResponsePayload = {
|
||||
fileId: FILE_ID,
|
||||
chunks: [
|
||||
{ chunkId: 0, bytes: new Uint8Array([10]) },
|
||||
{ chunkId: 1, bytes: new Uint8Array([20]) },
|
||||
],
|
||||
presence,
|
||||
generation: 5,
|
||||
};
|
||||
const wire = encodeResponsePayload(ok);
|
||||
const text = new TextDecoder().decode(wire);
|
||||
const obj = JSON.parse(text);
|
||||
// Swap the chunkIds.
|
||||
const tmp = obj.chunks[0].chunkId;
|
||||
obj.chunks[0].chunkId = obj.chunks[1].chunkId;
|
||||
obj.chunks[1].chunkId = tmp;
|
||||
const tampered = new TextEncoder().encode(JSON.stringify(obj));
|
||||
try {
|
||||
decodeResponsePayload(tampered);
|
||||
throw new Error("expected unordered rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.UNORDERED_RESPONSE);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* DMP-13 .. DMP-17 — Verification, trust decay, sample-verification rate.
|
||||
*
|
||||
* `TrustMap` tracks `(peerId, fileId)` advertised/missing counts;
|
||||
* `shouldSkip` implements the probabilistic decay; `shouldSample` picks
|
||||
* the sampling rate based on current trust score; `terminate` pins the
|
||||
* score to `-Infinity` after a bad-signature event (DMP-14) without
|
||||
* touching unrelated files (DMP-16).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import {
|
||||
classify,
|
||||
probabilityOfSkip,
|
||||
SAMPLE_RATE_HEALTHY,
|
||||
SAMPLE_RATE_SUSPECT,
|
||||
sampleRate,
|
||||
shouldSample,
|
||||
shouldSkip,
|
||||
TRUST_DECAY_SLOPE,
|
||||
TRUST_MISS_RATE_FLOOR,
|
||||
TRUST_SUSPECT_THRESHOLD,
|
||||
TrustMap,
|
||||
} from "../src/index.js";
|
||||
|
||||
const FILE = "F".repeat(64);
|
||||
const OTHER = "G".repeat(64);
|
||||
|
||||
function rngWith(values: number[]): Rng {
|
||||
let i = 0;
|
||||
const next = (): number => {
|
||||
const v = values[i % values.length] as number;
|
||||
i++;
|
||||
return v;
|
||||
};
|
||||
return {
|
||||
nextUint32: () => Math.floor(next() * 2 ** 32),
|
||||
nextFloat: next,
|
||||
bytes: (n: number) =>
|
||||
new Uint8Array(n).map(() => Math.floor(next() * 256)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("DMP-13 — verification ordering", () => {
|
||||
it("chunk with bad signature is discarded and not committed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const trust = new TrustMap();
|
||||
trust.recordAdvertised("peer", FILE, 100);
|
||||
// Simulate the request scheduler discovering a bad signature:
|
||||
// it terminates trust, never commits the chunk.
|
||||
trust.terminate("peer", FILE);
|
||||
expect(trust.canServe("peer", FILE)).toBe(false);
|
||||
const entry = trust.get("peer", FILE);
|
||||
expect(entry.terminated).toBe(true);
|
||||
expect(entry.score).toBe(Number.NEGATIVE_INFINITY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-14 — bad-signature trust termination", () => {
|
||||
it("bad signature sets per-file trust to -Infinity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.terminate("peer", FILE);
|
||||
const e = t.get("peer", FILE);
|
||||
expect(e.score).toBe(Number.NEGATIVE_INFINITY);
|
||||
expect(probabilityOfSkip(e)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-15 — probabilistic skip on advertised-but-missing", () => {
|
||||
it("miss rate below floor keeps skip probability at zero", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", FILE, 100);
|
||||
t.recordMissing("peer", FILE, 4);
|
||||
const e = t.get("peer", FILE);
|
||||
expect(probabilityOfSkip(e)).toBe(0);
|
||||
});
|
||||
|
||||
it("peer at 30% miss rate skipped on 50% of requests", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", FILE, 100);
|
||||
t.recordMissing("peer", FILE, 30);
|
||||
const e = t.get("peer", FILE);
|
||||
// (0.30 - 0.05) * 20 = 5, capped to 1.0
|
||||
expect(probabilityOfSkip(e)).toBe(1);
|
||||
// At 7.5% miss: (0.075 - 0.05) * 20 = 0.5
|
||||
const t2 = new TrustMap();
|
||||
t2.recordAdvertised("p2", FILE, 1000);
|
||||
t2.recordMissing("p2", FILE, 75);
|
||||
expect(probabilityOfSkip(t2.get("p2", FILE))).toBeCloseTo(0.5, 4);
|
||||
expect(TRUST_MISS_RATE_FLOOR).toBe(0.05);
|
||||
expect(TRUST_DECAY_SLOPE).toBe(20);
|
||||
});
|
||||
|
||||
it("shouldSkip uses the injected Rng", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", FILE, 100);
|
||||
t.recordMissing("peer", FILE, 30);
|
||||
const e = t.get("peer", FILE);
|
||||
// Probability is capped at 1, so any RNG value < 1 returns true.
|
||||
expect(shouldSkip(e, rngWith([0.99]))).toBe(true);
|
||||
});
|
||||
|
||||
it("shouldSkip returns false for healthy peers", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", FILE, 100);
|
||||
const e = t.get("peer", FILE);
|
||||
expect(shouldSkip(e, rngWith([0.5]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-16 — trust score per (peerId, fileId)", () => {
|
||||
it("trust score is per-file; one bad file does not affect others", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.terminate("peer", FILE);
|
||||
t.recordAdvertised("peer", OTHER, 50);
|
||||
expect(t.canServe("peer", FILE)).toBe(false);
|
||||
expect(t.canServe("peer", OTHER)).toBe(true);
|
||||
expect(t.get("peer", OTHER).score).toBe(1);
|
||||
});
|
||||
|
||||
it("dropPeer cleans up only that peer's entries", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("a", FILE, 1);
|
||||
t.recordAdvertised("b", FILE, 1);
|
||||
t.dropPeer("a");
|
||||
expect(t.size).toBe(1);
|
||||
});
|
||||
|
||||
it("dropFile cleans up that file across peers", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("a", FILE, 1);
|
||||
t.recordAdvertised("a", OTHER, 1);
|
||||
t.dropFile(FILE);
|
||||
expect(t.size).toBe(1);
|
||||
});
|
||||
|
||||
it("counts can be enumerated", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("a", FILE, 5);
|
||||
const entries = [...t];
|
||||
expect(entries.length).toBe(1);
|
||||
const first = entries[0] as (typeof entries)[number];
|
||||
expect(first.advertised).toBe(5);
|
||||
});
|
||||
|
||||
it("rejects negative deltas", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
expect(() => t.recordAdvertised("a", FILE, -1)).toThrow();
|
||||
expect(() => t.recordMissing("a", FILE, -2)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-17 — adaptive sample-verification rate", () => {
|
||||
it("suspect peer sampled at 1-in-10; healthy peer at 1-in-100", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("good", FILE, 100);
|
||||
t.recordAdvertised("bad", FILE, 100);
|
||||
t.recordMissing("bad", FILE, 10);
|
||||
const good = t.get("good", FILE);
|
||||
const bad = t.get("bad", FILE);
|
||||
expect(classify(good)).toBe("healthy");
|
||||
expect(classify(bad)).toBe("suspect");
|
||||
expect(sampleRate(good)).toBe(SAMPLE_RATE_HEALTHY);
|
||||
expect(sampleRate(bad)).toBe(SAMPLE_RATE_SUSPECT);
|
||||
expect(TRUST_SUSPECT_THRESHOLD).toBe(0.95);
|
||||
});
|
||||
|
||||
it("terminated peer is never sampled", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.terminate("peer", FILE);
|
||||
const entry = t.get("peer", FILE);
|
||||
expect(classify(entry)).toBe("terminated");
|
||||
expect(sampleRate(entry)).toBe(Number.POSITIVE_INFINITY);
|
||||
expect(shouldSample(entry, rngWith([0]))).toBe(false);
|
||||
});
|
||||
|
||||
it("shouldSample respects the rng draw and rate denominator", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", FILE, 100);
|
||||
const entry = t.get("peer", FILE);
|
||||
// Healthy: 1 in 100. RNG = 0.005 < 0.01 → sample.
|
||||
expect(shouldSample(entry, rngWith([0.005]))).toBe(true);
|
||||
// RNG = 0.5 → no sample.
|
||||
expect(shouldSample(entry, rngWith([0.5]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP — terminated entries", () => {
|
||||
it("recordAdvertised / recordMissing are no-ops once terminated", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.terminate("peer", FILE);
|
||||
t.recordAdvertised("peer", FILE, 50);
|
||||
t.recordMissing("peer", FILE, 10);
|
||||
const e = t.get("peer", FILE);
|
||||
expect(e.advertised).toBe(0);
|
||||
expect(e.missing).toBe(0);
|
||||
expect(e.score).toBe(Number.NEGATIVE_INFINITY);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
39
packages/networking/drive/drive-mesh-protocol/vite.config.ts
Normal file
39
packages/networking/drive/drive-mesh-protocol/vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/drive-mesh-protocol",
|
||||
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: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/bitpacker",
|
||||
"@suaveplan/bitpacker-compressed",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig();
|
||||
Reference in New Issue
Block a user