first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
# @suaveplan/service-discovery - Universal service discovery primitives: ServiceRegistry with TTL/expiry, query, AbortSignal-aware watch, optional storage persistence and EventBusLike integration. Shared foundation for every network-discovery-* plugin.
## Summary
Universal service-registry primitives. Provides the shared
`ServiceDescriptor`, `ServiceQuery`, `RegistryEvent`, and `ServiceRegistry`
contract consumed by every Wave-2 `network-discovery-*` plugin.
<!-- begin:auto-docs -->
## Documentation tree
- [service-registry](./src/core/service-registry/service-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [service-registry](./src/core/service-registry/service-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [service-registry](./src/core/service-registry/service-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [service-registry](./src/core/service-registry/service-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [service-registry](./src/core/service-registry/service-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [service-registry](./src/core/service-registry/service-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/service-discovery
```
## Basic usage
```ts
import { ServiceRegistry } from "@suaveplan/service-discovery";
const registry = new ServiceRegistry({ ttlMs: 30_000 });
registry.register({
id: "api-1",
name: "api",
address: "10.0.0.1:8080",
status: "healthy",
region: "us-east-1",
tags: ["v1"],
});
const healthy = registry.query({ name: "api", status: "healthy" });
```
## Advanced usage: watch + bus + persistence
```ts
import { ServiceRegistry } from "@suaveplan/service-discovery";
import type { EventBusLike } from "@suaveplan/types";
declare const bus: EventBusLike<{
"service:registered": import("@suaveplan/service-discovery").ServiceDescriptor;
"service:deregistered": import("@suaveplan/service-discovery").ServiceDescriptor;
"service:expired": import("@suaveplan/service-discovery").ServiceDescriptor;
}>;
declare const storage: import("@suaveplan/service-discovery").ServiceRegistryStorageLike;
const registry = new ServiceRegistry({
ttlMs: 30_000,
maxServices: 5_000,
bus,
storage,
persistKey: "service-registry:v1",
});
await registry.load();
const ac = new AbortController();
(async () => {
for await (const ev of registry.watch({ name: "api" }, ac.signal)) {
console.log(ev.type, ev.service.id);
}
})();
// later:
await registry.flush();
ac.abort();
registry.dispose();
```
## What ships
- `ServiceRegistry` — in-memory store with TTL expiry, query, AbortSignal-
aware `watch()` async iterator, optional persistent backing, optional
`EventBusLike` fan-out.
- Typed error class `SvcDiscError` + frozen `SVC_DISC_CODES` registry
(`DUPLICATE_REGISTRATION` / `QUERY_INVALID` / `REGISTRY_FULL` /
`INVALID_OPTIONS`).
- Zod schemas at every public boundary.
- OTel: `svc_disc.registrations_total{outcome}`, `svc_disc.queries_total`,
`svc_disc.query_duration_ms`, `svc_disc.active_services` gauge.
See each feature module's sibling `.md` for the full surface.

View File

@@ -0,0 +1,118 @@
# ServiceRegistry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ServiceRegistry` is the universal in-memory store + watch surface that
backs every `network-discovery-*` plugin (Wave 2). It models service
registration, query, AbortSignal-aware change streaming, TTL-based expiry,
optional persistent backing, and optional event-bus fan-out — all behind a
single class with Zod-validated boundaries, OTel instrumentation, and
typed `SvcDiscError` failures.
The registry is intentionally lightweight: no load balancing, no health
checks, no DHT — those concerns live in higher-tier plugins and consume
this primitive through its `query()` / `watch()` surface.
## Features
- Upsert `register()` with optional `strict: true` mode raising
`DUPLICATE_REGISTRATION`.
- `refresh(id)` updates `lastSeen` only.
- `deregister(id)` and `dispose()` for full teardown.
- `query(filter)` snapshot returning matching descriptors.
- `watch(filter, signal?)` async iterator pushing `registered` /
`deregistered` / `expired` events; terminates cleanly on abort or
`dispose()`.
- TTL expiry on every read; `prune()` for explicit sweeps. Pruned
entries emit `expired`.
- Capacity guard at `maxServices` (default 10 000) raising `REGISTRY_FULL`.
- Optional `storage` + `persistKey` snapshot via `flush()` / `load()`.
- Optional `bus` emitting `service:registered` / `:deregistered` /
`:expired` topics.
- OTel: `svc_disc.registrations_total{outcome}`, `svc_disc.queries_total`,
`svc_disc.query_duration_ms`, `svc_disc.active_services` gauge.
- Injectable `Clock` from `@suaveplan/types` (no module-scope `Date.now()`).
## Usage: Basic Example
```ts
import { ServiceRegistry } from "@suaveplan/service-discovery";
const registry = new ServiceRegistry({ ttlMs: 30_000 });
registry.register({
id: "api-1",
name: "api",
address: "10.0.0.1:8080",
status: "healthy",
tags: ["v1"],
});
const healthy = registry.query({ name: "api", status: "healthy" });
console.log(healthy.length); // 1
```
## Usage: Advanced Example
```ts
import { ServiceRegistry } from "@suaveplan/service-discovery";
const registry = new ServiceRegistry({
ttlMs: 5_000,
maxServices: 1_000,
});
const ac = new AbortController();
(async () => {
for await (const ev of registry.watch({ name: "api" }, ac.signal)) {
if (ev.type === "expired") console.warn("api instance gone:", ev.service.id);
}
})();
// later, on shutdown:
ac.abort();
registry.dispose();
```
## API Reference
- `new ServiceRegistry(config?: ServiceRegistryConfig)` — validates via
`ServiceRegistryConfigSchema`, throws `SvcDiscError(INVALID_OPTIONS)` on
malformed input.
- `register(input, options?)` — upsert by default; pass `{ strict: true }` to
reject duplicates. Returns the stored `ServiceDescriptor`.
- `refresh(id)` — touches `lastSeen` only; returns the new descriptor or
`undefined` if unknown / expired.
- `deregister(id)` — removes the entry; emits `deregistered`.
- `get(id)`, `list()`, `size` — readers (auto-prune).
- `query(filter)` — snapshot filter; throws `QUERY_INVALID` on bad shape.
- `watch(filter, signal?)` — async iterator of `RegistryEvent`; throws
`QUERY_INVALID` on bad shape.
- `prune()` — explicit TTL sweep; returns the count removed.
- `flush(signal?)` / `load(signal?)` — JSON snapshot through the configured
`storage`.
- `dispose()` — terminates all watchers, unbinds the OTel gauge.
## Implementation Notes
The watch iterator is a hand-rolled async producer (not an async generator)
so it can deliver an event to a pending `await iter.next()` consumer in
constant time without scheduling through an additional microtask. Events
that arrive while no consumer is awaiting are buffered in an in-order
FIFO and flushed on the next `next()` call. The iterator implements
`return()` so a `for await ... break;` cleanly removes the watcher slot
and detaches the AbortSignal listener.
TTL pruning is intentionally lazy and runs on every reader. This keeps
the registry constant-time-bounded by query rate rather than entry count,
and means a registry with no traffic costs nothing in the background.
Explicit `prune()` is offered for callers that need a deterministic sweep
(e.g. before snapshotting).
Bus integration uses the structural `EventBusLike<TMap>` type from
`@suaveplan/types`. The registry does not depend on `@suaveplan/events`
or `@suaveplan/network`, so Wave-2 plugins can wire any bus implementation
without inducing a cycle.

View File

@@ -0,0 +1,70 @@
# Errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured error class `SvcDiscError` and the frozen code registry
`SVC_DISC_CODES` used by every public boundary of `@suaveplan/service-discovery`.
suaveplan policy is to surface failure as discriminated, typed errors keyed by a
stable code, so consumers can branch on `error.code` rather than parsing
free-form messages or matching `instanceof` chains.
## Features
- `SVC_DISC_CODES` registry built with `defineCodes("SVC_DISC", [...])`.
- Four codes covering the full failure surface: `DUPLICATE_REGISTRATION`,
`QUERY_INVALID`, `REGISTRY_FULL`, `INVALID_OPTIONS`.
- `SvcDiscError` extends `SuaveplanError<SvcDiscErrorCode>` so the suaveplan-wide
message-resolver, redaction, and OTel span-event adapters all work.
## Usage: Basic Example
```ts
import {
SVC_DISC_CODES,
SvcDiscError,
} from "@suaveplan/service-discovery";
try {
// some registry op
} catch (err) {
if (err instanceof SvcDiscError && err.code === SVC_DISC_CODES.REGISTRY_FULL) {
// backpressure
}
}
```
## Usage: Advanced Example
```ts
import { SvcDiscError, SVC_DISC_CODES } from "@suaveplan/service-discovery";
throw new SvcDiscError(
SVC_DISC_CODES.DUPLICATE_REGISTRATION,
"service-registry.duplicate-registration",
{ cause: new Error("instance id collision") }
);
```
## API Reference
- `SVC_DISC_CODES` — frozen `Record<Code, "SVC_DISC.<CODE>">` produced by
`defineCodes`.
- `SvcDiscErrorCode` — string-literal union derived from the registry.
- `SvcDiscError(code, messageKey, options?)` — constructor; `options.cause`
threads the wrapped failure for OTel and redacted logging.
## Implementation Notes
All four codes are intentionally narrow. `INVALID_OPTIONS` covers every
Zod-rejected option struct (config, descriptor input). `QUERY_INVALID` is
separated from `INVALID_OPTIONS` because `query()` and `watch()` are
called every request, while options are validated once at construction —
keeping the two distinct lets dashboards differentiate frequent client
errors from rare misconfiguration. `REGISTRY_FULL` raises only on
`register()` over the soft cap; `refresh()` of an existing id never raises
this. `DUPLICATE_REGISTRATION` is reserved for the strict registration
path; the registry's default `upsert` semantics treat repeat `register()`
calls as refreshes and do not raise.

View File

@@ -0,0 +1,68 @@
# OTel instrumentation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Single-source OTel instruments for `@suaveplan/service-discovery`. Centralising
counter/histogram/gauge creation here keeps cardinality bounded, avoids the
common bug where multiple meters get created under similar names, and gives
dashboards a single canonical set of metric IDs to chart.
## Features
- `svc_disc.registrations_total{outcome}` counter — outcomes are `registered`,
`refreshed`, `rejected` (low-cardinality enum).
- `svc_disc.queries_total` counter — incremented once per `query()` call.
- `svc_disc.query_duration_ms` histogram — wall-clock latency per query.
- `svc_disc.active_services` observable gauge — bound to a registry's `size`
getter at construction; the meter pulls on scrape.
## Usage: Basic Example
```ts
import {
recordRegistration,
recordQuery,
recordQueryDuration,
} from "@suaveplan/service-discovery";
recordRegistration("registered");
recordQuery();
recordQueryDuration(1.42);
```
## Usage: Advanced Example
```ts
import { bindActiveServicesGauge } from "@suaveplan/service-discovery";
const services = new Set<string>();
const dispose = bindActiveServicesGauge(() => services.size);
// ...later, on shutdown:
dispose();
```
## API Reference
- `recordRegistration(outcome)` — increments
`svc_disc.registrations_total{outcome}` by 1. `outcome` is the
`RegistrationOutcome` union.
- `recordQuery()` — increments `svc_disc.queries_total` by 1.
- `recordQueryDuration(ms)` — records into `svc_disc.query_duration_ms`. Values
below zero are clamped to 0.
- `bindActiveServicesGauge(getSize)` — registers an OTel observable-gauge
callback. Returns a disposer that unbinds the callback.
## Implementation Notes
The `outcome` label is restricted to three string values so dashboards can
chart success vs refresh vs rejection without unbounded cardinality. The
gauge is observable (not synchronous) because the canonical "current size"
of the registry is best read on scrape — that way no separate metric write
is required on every add/delete and the gauge cannot drift from the actual
state. `bindActiveServicesGauge` returns its own disposer so registries can
properly tear down their instrumentation on `dispose()` without leaking
callbacks across test runs.

View File

@@ -0,0 +1,92 @@
# Types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Shared type contracts for `@suaveplan/service-discovery`. These types model the
universal service-registry domain: descriptors that identify and address a
service instance, queries that filter the registry, and registry events that
flow through the watch stream and optional event bus. Keeping the types in a
single sibling module means every Wave-2 discovery plugin (`crdt`, `peerjs`,
`trystero`, `webtorrent`, `gundb`, `hybrid`) can share the same descriptor
shape and event topics without re-declaring them.
## Features
- `ServiceDescriptor` — identity + routing + metadata + registry-managed
timestamps.
- `ServiceStatus` — `healthy` / `unhealthy` / `starting` / `stopping`.
- `ServiceQuery` — AND-conjoined filter by name / status / region / tag-superset.
- `RegistryEvent` — discriminated union of `registered` / `deregistered` /
`expired` emitted by the watch stream.
- `ServiceRegistryStorageLike` — structural subset of `@suaveplan/storage`
`AbstractStorage`, opt-in for cross-restart persistence.
- `ServiceRegistryEventMap` — bus topics consumed via the structural
`EventBusLike<TMap>` from `@suaveplan/types`.
- `ServiceRegistryConfig` — registry constructor options.
## Usage: Basic Example
```ts
import type {
ServiceDescriptor,
ServiceQuery,
} from "@suaveplan/service-discovery";
const svc: ServiceDescriptor = {
id: "api-1",
name: "api",
address: "10.0.0.1:8080",
status: "healthy",
registeredAt: 0,
lastSeen: 0,
};
const query: ServiceQuery = { name: "api", status: "healthy" };
```
## Usage: Advanced Example
```ts
import type {
ServiceRegistryConfig,
ServiceRegistryEventMap,
} from "@suaveplan/service-discovery";
import type { EventBusLike } from "@suaveplan/types";
declare const bus: EventBusLike<ServiceRegistryEventMap>;
const config: ServiceRegistryConfig = {
ttlMs: 30_000,
maxServices: 5_000,
bus,
};
```
## API Reference
- `ServiceStatus` — string-literal union; no methods.
- `ServiceDescriptor` — readonly fields; the registry stamps `registeredAt`
and `lastSeen` so callers may pass these as `0` when constructing inputs.
- `ServiceQuery` — every field optional; an empty query matches every entry.
- `RegistryEvent` — `{ type: "registered" | "deregistered" | "expired", service }`.
- `ServiceRegistryStorageLike` — `get`/`set`/`delete` keyed by string, value
is `Uint8Array`.
- `ServiceRegistryConfig.maxServices` — soft cap, default `10_000`. Adding a
new service when the registry is full raises `SVC_DISC.REGISTRY_FULL`.
- `ServiceRegistryConfig.ttlMs` — default `60_000`; entries older than this
are pruned and emit `expired`.
## Implementation Notes
The descriptor's `registeredAt` and `lastSeen` are wall-clock milliseconds
(`number`) so the registry can use the injectable `Clock` from
`@suaveplan/types`. Bus integration uses the structural `EventBusLike<TMap>`
type — not a hard dep on `@suaveplan/events` or `@suaveplan/network` — so
this package stays universal and avoids cycles when Wave-2 plugins
register against the same bus their network node uses. The storage shim
matches the `get` / `set` / `delete` slice of `AbstractStorage` so callers
can pass a `MemoryStorage`, `OPFSStorage`, or `S3Storage` without the
registry depending on `@suaveplan/storage` directly.