first commit
This commit is contained in:
149
docs/networking/network/network-discovery-crdt/README.md
Normal file
149
docs/networking/network/network-discovery-crdt/README.md
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,81 @@
|
||||
# automerge-provider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Automerge-backed implementation of the `CRDTProviderAdapter` contract. Wraps an `AutomergeRepoLike` (built by the caller-supplied `AutomergeRepoFactory`) that hides the layered network-adapter setup (`BroadcastChannelNetworkAdapter`, `BrowserWebSocketClientAdapter`) plus the optional `IndexedDBStorageAdapter` from the orchestrator. The orchestrator only ever talks to the structural surface; production wires the factory to a real `@automerge/automerge-repo` `Repo`, and tests wire a deterministic in-memory `FakeAutomergeRepo`.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure adapter — never reaches into `@automerge/automerge-repo` directly.
|
||||
- Repo `peer` event fans out into `onPeer` callback for every advertised peer the repo currently knows about (the structural `listPeers()` call lets the orchestrator stay synchronous with no awareness payloads of its own).
|
||||
- Repo `peer-disconnected` event mirrors into `onPeerLost`.
|
||||
- Initial `onSynced()` + a snapshot flush of `listPeers()` so a late-joining peer immediately sees the full set.
|
||||
- Idempotent `destroy()` calls `repo.shutdown()` and swallows individual library errors.
|
||||
- Factory wrapped in try / catch — failures map to `AUTOMERGE_SYNC_FAILED`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { AutomergeProviderAdapter } from "@suaveplan/network-discovery-crdt";
|
||||
|
||||
const adapter = new AutomergeProviderAdapter(
|
||||
"demo",
|
||||
{ enableBroadcastChannel: true },
|
||||
(room, opts) => makeFakeRepo(room, opts)
|
||||
);
|
||||
|
||||
const myId = await adapter.initialize({
|
||||
onPeer: (m) => console.log("peer", m.id),
|
||||
onPeerLost: (id) => console.log("lost", id),
|
||||
onSynced: () => console.log("synced"),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { AutomergeProviderAdapter } from "@suaveplan/network-discovery-crdt";
|
||||
import { Repo } from "@automerge/automerge-repo";
|
||||
import { BroadcastChannelNetworkAdapter } from "@automerge/automerge-repo-network-broadcastchannel";
|
||||
import { BrowserWebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
|
||||
import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
|
||||
|
||||
const adapter = new AutomergeProviderAdapter(
|
||||
"team-A",
|
||||
{
|
||||
websocketUrl: "wss://sync.internal.example",
|
||||
enableBroadcastChannel: true,
|
||||
enableIndexedDB: true,
|
||||
storageKey: "team-a",
|
||||
},
|
||||
(room, opts) => {
|
||||
const network = [];
|
||||
if (opts.enableBroadcastChannel !== false)
|
||||
network.push(new BroadcastChannelNetworkAdapter());
|
||||
if (opts.websocketUrl)
|
||||
network.push(new BrowserWebSocketClientAdapter(opts.websocketUrl));
|
||||
const storage = opts.enableIndexedDB !== false
|
||||
? new IndexedDBStorageAdapter(opts.storageKey ?? "automerge-discovery")
|
||||
: undefined;
|
||||
const repo = new Repo({ network, storage });
|
||||
return wrapAsRepoLike(repo, room);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`new AutomergeProviderAdapter(room, opts, factory)`** — `opts: AutomergeConfig`, `factory: AutomergeRepoFactory`.
|
||||
- **`initialize(callbacks, signal?)`** — returns the local peer id; throws `NetDiscCRDTError(AUTOMERGE_SYNC_FAILED)` on factory failure, `DISPOSED` if `destroy()` was already called.
|
||||
- **`announce(metadata)`** — delegates to `repo.announce(metadata)`.
|
||||
- **`leave()`** — no-op (Automerge has no first-class leave; remote peers drop us via the cleanup-loop TTL).
|
||||
- **`listKnownPeers()`** — snapshot of `repo.listPeers()`.
|
||||
- **`destroy()`** — `repo.shutdown()`; idempotent, swallows errors.
|
||||
- **`isConnected: boolean`** — `true` while the repo is alive.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Automerge's `Repo` does not expose a first-class peer-list at the time of this migration — the structural `listPeers()` on `AutomergeRepoLike` is the contract the production factory has to synthesise from `Repo` internals, and the test fake implements directly. The `leave()` no-op is deliberate: Automerge does not have a notion of "I am leaving the swarm" the way `y-webrtc` does, and crafting one synthetically (e.g. by writing a tombstone) would conflict with the `Repo`'s own GC semantics. Stale peers are eventually dropped by the cleanup loop in `CRDTIntervals`. The `factory` is the only point of contact with the real Automerge libraries, which keeps the adapter shippable as a universal package — no `import "@automerge/automerge-repo"` ever fires at module-load time.
|
||||
@@ -0,0 +1,81 @@
|
||||
# crdt-discovery
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Orchestrator class for `@suaveplan/network-discovery-crdt`. Implements `DiscoveryStrategy` from `@suaveplan/network-bootstrap` so a `CRDTDiscovery` instance plugs into a bootstrap chain unchanged. Owns the lifecycle (`start` / `stop` / `dispose`), wires the configured provider adapter (Yjs or Automerge) into the shared `CRDTPeerManager` + `CRDTIntervals` infrastructure, and translates between the package-private `CRDTPeerMetadata` shape and the bootstrap-canonical `PeerInfo` shape on `discover()`.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict construction-time provider selection (`provider: "yjs" | "automerge"`) — never branches on provider strings at runtime once constructed.
|
||||
- Zod-validated `CRDTDiscoveryConfig` with per-provider gates (`yjs` block + `yjsDocFactory` are mandatory when `provider === "yjs"`; same for automerge).
|
||||
- AbortSignal threaded through every async path (`start` / `stop` / `discover` / `isAvailable` / `announce`).
|
||||
- Optional `EventBusLike<CRDTEventMap>` integration — emits `crdt:started`, `crdt:peer-discovered`, `crdt:peer-lost`, `crdt:sync-failed`.
|
||||
- Optional `@suaveplan/service-discovery` `ServiceRegistry` integration — discovered peers auto-register / auto-deregister.
|
||||
- Idempotent `start()` / `stop()` / `dispose()`; `dispose()` cannot be undone.
|
||||
- OTel spans (`disc_crdt.start`, `disc_crdt.provider.<yjs|automerge>.sync`) + counters / histograms.
|
||||
- Status snapshots (`getSyncStatus`, `getStatus`) for dashboards.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { CRDTDiscovery } from "@suaveplan/network-discovery-crdt";
|
||||
|
||||
const discovery = new CRDTDiscovery({
|
||||
provider: "yjs",
|
||||
room: "demo",
|
||||
yjs: { signaling: ["wss://signal.local"] },
|
||||
yjsDocFactory: (room, opts) => ({
|
||||
doc: makeDoc(room),
|
||||
provider: makeProvider(opts),
|
||||
}),
|
||||
});
|
||||
await discovery.start();
|
||||
const peers = await discovery.discover(1000);
|
||||
await discovery.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { CRDTDiscovery } from "@suaveplan/network-discovery-crdt";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { ServiceRegistry } from "@suaveplan/service-discovery";
|
||||
import type { CRDTEventMap } from "@suaveplan/network-discovery-crdt/types";
|
||||
|
||||
const bus = new EventBus<CRDTEventMap>();
|
||||
const registry = new ServiceRegistry();
|
||||
const discovery = new CRDTDiscovery({
|
||||
provider: "automerge",
|
||||
room: "team-A",
|
||||
automerge: { websocketUrl: "wss://sync.example", enableIndexedDB: true },
|
||||
automergeRepoFactory: (room, opts) => makeAutomergeRepo(room, opts),
|
||||
interval: 5_000,
|
||||
timeout: 30_000,
|
||||
bus,
|
||||
registry,
|
||||
});
|
||||
|
||||
bus.on("crdt:peer-discovered", (e) => console.log("peer", e.peer.id));
|
||||
const ctrl = new AbortController();
|
||||
await discovery.start(ctrl.signal);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`new CRDTDiscovery(config, clock?)`** — `clock` defaults to `Date.now`. Throws `NetDiscCRDTError(INVALID_OPTIONS)` for any schema failure or missing per-provider factory.
|
||||
- **`start(signal?)`** — initialise adapter + schedule loops; emits `crdt:started`. Throws `DISPOSED` after dispose.
|
||||
- **`stop(signal?)`** — leave + tear down. Idempotent. Pre-aborted signal skips the leave broadcast.
|
||||
- **`discover(timeoutMs, signal?)`** — returns `PeerInfo[]` (bootstrap shape). Throws `DISPOSED` after dispose.
|
||||
- **`isAvailable(signal?)`** — `true` while running + not disposed.
|
||||
- **`announce(peerInfo, signal?)`** — push an explicit announce into the shared CRDT. Throws `PROVIDER_FAILED` when not running.
|
||||
- **`getMyPeerId()` / `getPeers()` / `getPeerCount()` / `getProvider()` / `getSyncStatus()` / `getStatus()`** — read accessors.
|
||||
- **`dispose()`** — terminal; safe to call repeatedly.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The construction branch on `provider` is the only place the orchestrator ever sees the provider string. From that point on every call goes through `this.adapter` — switching backends would mean instantiating a new `CRDTDiscovery`, which is intentional: Yjs and Automerge documents are not interchangeable, and a runtime swap would silently lose state. The `discover()` method always returns the snapshot the peer-manager currently holds; awareness updates flow asynchronously through the adapter callbacks, so a caller that needs deterministic ordering should subscribe to `crdt:peer-discovered` on the bus instead of polling `discover()`.
|
||||
|
||||
The optional `bus` and `registry` integrations live in the `CRDTPeerManager`; the orchestrator only owns the lifecycle-level `crdt:started` / `crdt:sync-failed` emissions. `crdt:sync-failed` is reused for two distinct conditions — provider init failure (fatal, surfaces the thrown error reason) and `PEER_REJECTED` from a peer-manager admit (non-fatal, reason `"room_full"`). Consumers branching on it should inspect the reason string.
|
||||
@@ -0,0 +1,73 @@
|
||||
# crdt-intervals
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Owns the three periodic loops every `CRDTDiscovery` instance needs: announce (re-broadcast local presence), heartbeat (refresh `lastSeen` so peers do not garbage-collect this peer), and cleanup (drop stale remote peers). Uses one `@suaveplan/timing` `Scheduler` so disposing the strategy cancels every pending timer atomically — there is no equivalent guarantee with raw `setInterval` handles, and the upstream version leaked timers on aborted shutdowns.
|
||||
|
||||
## Features
|
||||
|
||||
- One `Scheduler` per `CRDTIntervals` instance — `stop()` calls `scheduler.dispose()`, draining every pending tick.
|
||||
- Self-rearming loops via `scheduler.schedule(...)`. Each tick re-queues itself; cancellation breaks the chain.
|
||||
- Loops short-circuit when `isRunning()` returns `false` or `getMyPeerId()` is still `null`, so a slow `start()` cannot fire announces against an uninitialised adapter.
|
||||
- Cleanup respects the configured `timeout` (ms) and routes drops through the caller's `handlePeerLost` so the peer-manager / bus / registry side-effects stay consistent.
|
||||
- Records `disc_crdt.reconcile_duration_ms{provider}` on every announce.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { CRDTIntervals } from "@suaveplan/network-discovery-crdt";
|
||||
|
||||
const intervals = new CRDTIntervals({
|
||||
clock: () => Date.now(),
|
||||
announceIntervalMs: 1_000,
|
||||
heartbeatIntervalMs: 500,
|
||||
cleanupIntervalMs: 1_000,
|
||||
timeoutMs: 30_000,
|
||||
provider: "yjs",
|
||||
adapter,
|
||||
peers: new Map(),
|
||||
getMyPeerId: () => "p1",
|
||||
isRunning: () => true,
|
||||
selfMetadata: () => ({
|
||||
id: "p1",
|
||||
addresses: [],
|
||||
metadata: {},
|
||||
lastSeen: 0,
|
||||
provider: "yjs",
|
||||
}),
|
||||
handlePeerLost: (id) => console.log("lost", id),
|
||||
});
|
||||
|
||||
intervals.start();
|
||||
// later:
|
||||
intervals.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { CRDTIntervals } from "@suaveplan/network-discovery-crdt";
|
||||
import { createScheduler } from "@suaveplan/timing";
|
||||
|
||||
const intervals = new CRDTIntervals(
|
||||
{
|
||||
/* ...deps */
|
||||
} as never,
|
||||
(clock) => createScheduler({ clock, maxConcurrentTasks: 4 })
|
||||
);
|
||||
intervals.start();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`new CRDTIntervals(deps, schedulerFactory?)`** — deps shape documented above; `schedulerFactory` defaults to `(c) => createScheduler({ clock: c })`.
|
||||
- **`start()`** — fire-and-forget queue of the three loops. Safe to call multiple times.
|
||||
- **`stop()`** — dispose every pending timer. Idempotent.
|
||||
- **`isStopped: boolean`** — true after `stop()` has fired.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The heartbeat loop deliberately re-runs the same `selfMetadata()` builder as the announce loop, only at a shorter cadence. The underlying CRDT (Yjs Y.Map or Automerge document) de-duplicates the write via its own conflict-resolution — a heartbeat that lands on the same data is a no-op. The cleanup loop iterates the live `peers` map directly so we never snapshot megabytes of peer state per tick. A peer whose `lastSeen` is younger than `timeoutMs` is left untouched; entries past the threshold are dropped through the caller's `handlePeerLost` so the side-effects (event-bus emission, registry deregistration) stay in one place. The OTel reconciliation-duration histogram is recorded around the announce step because that is the operation operations teams need to chart — heartbeat is too noisy and cleanup is constant-time per peer.
|
||||
@@ -0,0 +1,80 @@
|
||||
# crdt-peer-manager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
In-memory tracking layer for the deduped set of `CRDTPeerMetadata` records observed via the underlying CRDT awareness layer. Owns the lifecycle deltas — `admit` (insert / update), `touch` (heartbeat), `drop` (leave), `clear` (full reset) — and fans every delta out to the optional `EventBusLike<CRDTEventMap>` and the optional `@suaveplan/service-discovery` `ServiceRegistry`. Enforces the configured `maxPeers` cap on insert, throwing `NetDiscCRDTError` with code `PEER_REJECTED` rather than silently dropping records.
|
||||
|
||||
## Features
|
||||
|
||||
- O(1) lookup / insert / delete via `Map<string, CRDTPeerMetadata>`.
|
||||
- Injectable `Clock` (every `lastSeen` write goes through the clock — deterministic in tests).
|
||||
- Optional `EventBusLike` integration emits `crdt:peer-discovered` and `crdt:peer-lost` for every transition.
|
||||
- Optional `ServiceRegistry` mirror — discovered peers register as `crdt:<room>:<peerId>` services, auto-deregister on drop.
|
||||
- OTel `disc_crdt.peers_discovered_total{provider,outcome}` increments per outcome.
|
||||
- Hard `maxPeers` cap with structured `PEER_REJECTED` failure.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { CRDTPeerManager } from "@suaveplan/network-discovery-crdt";
|
||||
|
||||
const peers = new CRDTPeerManager({
|
||||
clock: () => Date.now(),
|
||||
provider: "yjs",
|
||||
maxPeers: 100,
|
||||
room: "demo",
|
||||
});
|
||||
|
||||
peers.admit({
|
||||
id: "p1",
|
||||
addresses: ["wrtc-1"],
|
||||
metadata: {},
|
||||
lastSeen: 0,
|
||||
provider: "yjs",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { CRDTPeerManager } from "@suaveplan/network-discovery-crdt";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { ServiceRegistry } from "@suaveplan/service-discovery";
|
||||
import type { CRDTEventMap } from "@suaveplan/network-discovery-crdt/types";
|
||||
|
||||
const bus = new EventBus<CRDTEventMap>();
|
||||
const registry = new ServiceRegistry();
|
||||
const peers = new CRDTPeerManager({
|
||||
clock: () => Date.now(),
|
||||
bus,
|
||||
registry,
|
||||
provider: "automerge",
|
||||
maxPeers: 50,
|
||||
room: "team-A",
|
||||
});
|
||||
|
||||
bus.on("crdt:peer-discovered", (e) => console.log("peer", e.peer.id));
|
||||
peers.admit({
|
||||
id: "p2",
|
||||
addresses: ["wrtc-2"],
|
||||
metadata: { app: "demo" },
|
||||
lastSeen: 0,
|
||||
provider: "automerge",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`new CRDTPeerManager(deps)`** — `deps: { clock, bus?, registry?, provider, maxPeers, room }`.
|
||||
- **`admit(metadata)`** — returns `"added"` | `"updated"`; throws `NetDiscCRDTError(PEER_REJECTED)` when the cap is exceeded on insert.
|
||||
- **`touch(peerId)`** — refresh `lastSeen` of an existing peer; no-op when unknown.
|
||||
- **`drop(peerId)`** — remove peer; returns `boolean` indicating whether the peer existed.
|
||||
- **`list()` / `get(peerId)` / `size()` / `getMap()`** — read accessors.
|
||||
- **`clear()`** — drop every peer, firing `crdt:peer-lost` for each.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `getMap()` accessor returns the internal map directly so the cleanup-interval loop in `CRDTIntervals` can iterate without snapshotting on every tick. This is safe because `CRDTIntervals` only ever calls `drop()` on it (never raw `delete`), so the bus / registry side-effects always fire. The dedup behaviour relies on stable peer ids; if a CRDT backend rotates ids on reconnect, the new id appears as a new peer (intentional — we surface what the CRDT sees). `maxPeers` is enforced only on insert; an `admit` that updates an existing peer never throws, even if `size() > maxPeers` due to caller-initiated cap-shrinking.
|
||||
@@ -0,0 +1,79 @@
|
||||
# yjs-provider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Yjs-backed implementation of the `CRDTProviderAdapter` contract. Wraps a `Y.Doc` plus the `y-webrtc` `WebrtcProvider` built by the caller-supplied `YjsDocFactory`. Owns the `peers` `Y.Map` observe handler, the `synced` + `peers` provider events, and the destroy semantics. Designed so production wires `yjsDocFactory: (room, opts) => { const doc = new Doc(); const provider = new WebrtcProvider(room, doc, opts); return { doc, provider }; }`, while tests pass a deterministic in-memory `FakeYjs` that implements the same structural surface.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure adapter — never reaches into `yjs` or `y-webrtc` directly. Every dependency on the upstream packages flows through `YDocLike` / `YMapLike` / `WebrtcProviderLike` structural types.
|
||||
- Observe handler converts Y.Map deltas (add / update / delete) into `onPeer` / `onPeerLost` callbacks, ignoring the local peer.
|
||||
- `provider.on("synced")` flushes the existing `peers` map into the callbacks so a late-joining peer immediately sees the full set.
|
||||
- `provider.on("peers")` mirrors removed transport peers into `onPeerLost`.
|
||||
- Idempotent `destroy()` swallows any teardown error from the upstream library.
|
||||
- Caller-supplied factory is wrapped in a try/catch so factory throws map to `YJS_AWARENESS_FAILED` rather than escaping unstructured.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { YjsProviderAdapter } from "@suaveplan/network-discovery-crdt";
|
||||
|
||||
const adapter = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://signal.local"] },
|
||||
(room, opts) => ({ doc: makeDoc(room), provider: makeProvider(opts) })
|
||||
);
|
||||
|
||||
const myId = await adapter.initialize({
|
||||
onPeer: (m) => console.log("peer", m.id),
|
||||
onPeerLost: (id) => console.log("lost", id),
|
||||
onSynced: () => console.log("synced"),
|
||||
});
|
||||
adapter.announce({
|
||||
id: myId,
|
||||
addresses: [],
|
||||
metadata: {},
|
||||
lastSeen: 0,
|
||||
provider: "yjs",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { YjsProviderAdapter } from "@suaveplan/network-discovery-crdt";
|
||||
import { Doc } from "yjs";
|
||||
import { WebrtcProvider } from "y-webrtc";
|
||||
|
||||
const adapter = new YjsProviderAdapter(
|
||||
"team-A",
|
||||
{ signaling: ["wss://signal.internal.example"], password: "secret" },
|
||||
(room, opts) => {
|
||||
const doc = new Doc();
|
||||
const provider = new WebrtcProvider(room, doc, {
|
||||
signaling: [...opts.signaling],
|
||||
password: opts.password,
|
||||
maxConns: opts.maxConns,
|
||||
peerExchange: opts.peerExchange ?? true,
|
||||
});
|
||||
return { doc: doc as never, provider: provider as never };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`new YjsProviderAdapter(room, opts, factory)`** — `opts: YjsSignalingConfig`, `factory: YjsDocFactory`.
|
||||
- **`initialize(callbacks, signal?)`** — returns the local peer id; throws `NetDiscCRDTError(YJS_AWARENESS_FAILED)` on factory failure, `DISPOSED` if `destroy()` was already called.
|
||||
- **`announce(metadata)`** — `set` on the underlying `Y.Map`.
|
||||
- **`leave()`** — `delete` on the underlying `Y.Map`; idempotent.
|
||||
- **`listKnownPeers()`** — snapshot of the `Y.Map` minus the local peer.
|
||||
- **`destroy()`** — unobserve + destroy provider + destroy doc; idempotent.
|
||||
- **`isConnected: boolean`** — mirrors the underlying provider's `connected` flag.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The adapter never falls back to the public `wss://signaling.yjs.dev` URL — every caller MUST supply a non-empty `signaling` array. Removing the upstream default was the most important security uplift in the migration: hard-coding a public signalling server leaks discovery metadata to a third party. Teardown swallows individual library exceptions because the upstream `yjs` and `y-webrtc` packages have historically thrown from `destroy()` when called on a partially-initialised instance, and we never want a teardown to crash the orchestrator. The factory wrapper translates any thrown value into `YJS_AWARENESS_FAILED` so consumers do not need to know about the upstream error types.
|
||||
@@ -0,0 +1,62 @@
|
||||
# errors — `NetDiscCRDTError` registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the frozen `NET_DISC_CRDT_CODES` registry and `NetDiscCRDTError` class every public boundary in `@suaveplan/network-discovery-crdt` throws on failure. Centralising the code list keeps consumers' branching exhaustive: a `switch (error.code)` either covers the whole set or fails type-checking.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven stable codes covering every failure path in the package: provider lifecycle (`PROVIDER_FAILED`), state-machine invariants (`STATE_DESYNC`, `DISPOSED`), peer-admission (`PEER_REJECTED`), input validation (`INVALID_OPTIONS`), and per-provider sync failures (`YJS_AWARENESS_FAILED`, `AUTOMERGE_SYNC_FAILED`).
|
||||
- Codes registered via `defineCodes("NET_DISC_CRDT", [...])` so they appear in the repo-wide error registry and never collide with other packages' codes.
|
||||
- Single `NetDiscCRDTError` class extending `SuaveplanError` — opaque enough to be caught with `instanceof`, structured enough to branch on `error.code`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
NET_DISC_CRDT_CODES,
|
||||
NetDiscCRDTError,
|
||||
} from "@suaveplan/network-discovery-crdt";
|
||||
|
||||
throw new NetDiscCRDTError(
|
||||
NET_DISC_CRDT_CODES.PROVIDER_FAILED,
|
||||
"errors.crdt.provider_failed"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await discovery.start();
|
||||
} catch (err) {
|
||||
if (err instanceof NetDiscCRDTError) {
|
||||
switch (err.code) {
|
||||
case NET_DISC_CRDT_CODES.INVALID_OPTIONS:
|
||||
case NET_DISC_CRDT_CODES.DISPOSED:
|
||||
// fatal — re-throw
|
||||
throw err;
|
||||
case NET_DISC_CRDT_CODES.PROVIDER_FAILED:
|
||||
case NET_DISC_CRDT_CODES.YJS_AWARENESS_FAILED:
|
||||
case NET_DISC_CRDT_CODES.AUTOMERGE_SYNC_FAILED:
|
||||
// transient — retry with backoff
|
||||
break;
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`NET_DISC_CRDT_CODES`** — frozen string-literal registry; keys equal the value strings.
|
||||
- **`NetDiscCRDTErrorCode`** — TypeScript union of every value in the registry.
|
||||
- **`NetDiscCRDTError`** — `class NetDiscCRDTError extends SuaveplanError<NetDiscCRDTErrorCode>`. Constructor: `(code, messageKey, options?: SuaveplanErrorOptions)`. `options.cause` accepts any thrown value and is preserved on the resulting `Error` chain.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The seven-code surface is deliberately small. Provider-level distinctions (`YJS_AWARENESS_FAILED` vs `AUTOMERGE_SYNC_FAILED`) exist because the two underlying CRDT libraries have very different failure modes and a single "sync failed" code would conflate them in consumer dashboards. `STATE_DESYNC` is reserved for cases the CRDT's own conflict-resolution cannot fix — e.g. an awareness payload that fails its Zod schema even after merging. The orchestrator never throws on simple peer churn (joins / leaves through normal CRDT operations); those flow through the `EventBusLike` instead.
|
||||
@@ -0,0 +1,56 @@
|
||||
# otel — OpenTelemetry instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Centralises every OTel instrument the package emits. Concentrating instrument construction in a single module keeps tracer / meter names consistent and stops the rest of the code from importing `@opentelemetry/api` directly. The instruments cover the discovery-strategy lifecycle (start span, per-provider sync span) and the two dimensions the operations team needs to chart: peer-discovery throughput per outcome (found / duplicate / rejected) per provider, and reconciliation-loop duration per provider.
|
||||
|
||||
## Features
|
||||
|
||||
- Single tracer / meter under `@suaveplan/network-discovery-crdt`.
|
||||
- `withStartSpan` and `withProviderSyncSpan` wrappers managing span lifecycle, so the orchestrator never has to call `span.end()` manually.
|
||||
- `disc_crdt.peers_discovered_total{provider,outcome}` counter — three-label-cardinality outcome enum (`found` | `duplicate` | `rejected`) so dashboards stay bounded.
|
||||
- `disc_crdt.reconcile_duration_ms{provider}` histogram tracking awareness-reconciliation wall-clock.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
recordPeersDiscovered,
|
||||
withStartSpan,
|
||||
} from "@suaveplan/network-discovery-crdt";
|
||||
|
||||
await withStartSpan(async (span) => {
|
||||
span.setAttribute("room", "demo");
|
||||
recordPeersDiscovered("yjs", "found", 1);
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
recordReconcileDuration,
|
||||
withProviderSyncSpan,
|
||||
} from "@suaveplan/network-discovery-crdt";
|
||||
|
||||
const t0 = Date.now();
|
||||
await withProviderSyncSpan("automerge", async () => {
|
||||
// adapter-level sync
|
||||
});
|
||||
recordReconcileDuration("automerge", Date.now() - t0);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`withStartSpan<T>(fn)`** — runs `fn(span)` inside a `disc_crdt.start` span, always closes the span.
|
||||
- **`withProviderSyncSpan<T>(provider, fn)`** — runs `fn(span)` inside a `disc_crdt.provider.<provider>.sync` span; auto-attaches the `provider` attribute.
|
||||
- **`recordPeersDiscovered(provider, outcome, count?)`** — increments `disc_crdt.peers_discovered_total` (no-op when `count <= 0`).
|
||||
- **`recordReconcileDuration(provider, ms)`** — records on `disc_crdt.reconcile_duration_ms`; clamps `ms` at `0`.
|
||||
- **`PeerDiscoveryOutcome`** — `"found" | "duplicate" | "rejected"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The outcome label-set is intentionally three-valued. Adding finer-grained outcomes (e.g. "stale" / "expired") would explode counter cardinality without giving the dashboards information they cannot already derive from the room-state snapshot. Both span helpers always call `span.end()` in a `finally` block, so a thrown error inside the wrapped function still closes the span cleanly. Negative durations are clamped at zero because OTel's histogram contract rejects negative samples and a clock-skew event from an injected `Clock` should not crash discovery.
|
||||
@@ -0,0 +1,70 @@
|
||||
# types — public surface of `@suaveplan/network-discovery-crdt`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
This module hosts every type the public API of `@suaveplan/network-discovery-crdt` exposes: the `CRDTProvider` discriminator, the `CRDTDiscoveryConfig` / `ResolvedCRDTDiscoveryConfig` pair, the shared `CRDTProviderAdapter` contract both Yjs and Automerge backends conform to, the wire-format message envelope, the structural `YDocLike` / `YMapLike` / `WebrtcProviderLike` / `AutomergeRepoLike` views the test suite implements without touching real CRDT libraries, and the typed `CRDTEventMap` emitted on an optional `EventBusLike`. Keeping every type in one module means a downstream consumer can `import type { CRDTDiscoveryConfig } from "@suaveplan/network-discovery-crdt/types"` without pulling in the orchestrator class.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-source `CRDTProvider` discriminator (`"yjs" | "automerge"`) referenced everywhere.
|
||||
- Strict `CRDTDiscoveryConfig` requiring per-provider config + factory at construction time.
|
||||
- `CRDTProviderAdapter` interface — both providers conform; orchestrator never branches on `provider` strings.
|
||||
- Structural `YDocLike` / `YMapLike` / `WebrtcProviderLike` / `AutomergeRepoLike` views allowing real-implementation test fakes (no `vi.mock`).
|
||||
- Typed `CRDTEventMap` for the optional `@suaveplan/events` bus integration.
|
||||
- Frozen `CRDTMessageType` registry for the wire format (kept for parity with the rest of the discovery family).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
CRDTDiscoveryConfig,
|
||||
CRDTProvider,
|
||||
} from "@suaveplan/network-discovery-crdt/types";
|
||||
|
||||
const cfg: CRDTDiscoveryConfig = {
|
||||
provider: "yjs",
|
||||
room: "demo",
|
||||
yjs: { signaling: ["wss://signal.local"] },
|
||||
yjsDocFactory: (room, opts) => ({ doc: makeDoc(), provider: makeProvider() }),
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
CRDTProviderAdapter,
|
||||
CRDTProviderCallbacks,
|
||||
} from "@suaveplan/network-discovery-crdt/types";
|
||||
|
||||
class MyProvider implements CRDTProviderAdapter {
|
||||
public readonly kind = "yjs" as const;
|
||||
public get isConnected() { return true; }
|
||||
public async initialize(cb: CRDTProviderCallbacks): Promise<string> { return "id"; }
|
||||
public announce(): void {}
|
||||
public leave(): void {}
|
||||
public listKnownPeers() { return []; }
|
||||
public destroy(): void {}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`CRDTProvider`** — `"yjs" | "automerge"` discriminator.
|
||||
- **`CRDTMessageType`** / **`CRDTMessageTypeValue`** — frozen wire-format message-type registry.
|
||||
- **`CRDTPeerMetadata`** — awareness-document peer record.
|
||||
- **`YjsSignalingConfig`** / **`AutomergeConfig`** — per-provider option blocks.
|
||||
- **`YDocLike`** / **`YMapLike`** / **`YMapEventLike`** / **`WebrtcProviderLike`** — structural Yjs surfaces.
|
||||
- **`YjsDocFactory`** — `(room, opts) => { doc, provider }` injection point.
|
||||
- **`AutomergeRepoLike`** / **`AutomergeRepoFactory`** — structural Automerge surfaces + injection.
|
||||
- **`CRDTProviderAdapter`** / **`CRDTProviderCallbacks`** — shared provider contract.
|
||||
- **`CRDTDiscoveryConfig`** / **`ResolvedCRDTDiscoveryConfig`** — public + post-resolve config.
|
||||
- **`CRDTSyncStatus`** / **`CRDTConnectionStatus`** — status snapshots.
|
||||
- **`CRDTEventMap`** + the four event types — typed `EventBusLike` payloads.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
This module is type-only — every export is a `type` / `interface`, except `CRDTMessageType` which is a frozen string-literal record kept compatible with the upstream wire format. Treating the wire-format as a record (not a TS `enum`) lets it survive `isolatedModules` and avoids the implicit `const enum` foot-gun. The `CRDTProviderAdapter` interface is the package's most important abstraction: it lets the orchestrator call `adapter.initialize()`, `adapter.announce()`, `adapter.leave()`, `adapter.destroy()` without ever knowing whether it's driving Yjs awareness or an Automerge repo. The structural `*Like` views deliberately keep only the surface the discovery layer needs — they are smaller than the real Yjs / Automerge type surfaces, which keeps the test fakes implementable in ~100 lines each.
|
||||
Reference in New Issue
Block a user