Initial Commit

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

View File

@@ -0,0 +1,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,57 @@
{
"name": "@suaveplan/service-discovery",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"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/types.d.ts", "default": "./dist/types/types.js" },
"require": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" }
},
"./service-registry": {
"import": { "types": "./dist/core/service-registry/service-registry.d.ts", "default": "./dist/core/service-registry/service-registry.js" },
"require": { "types": "./dist/core/service-registry/service-registry.d.ts", "default": "./dist/core/service-registry/service-registry.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@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"
}
}

View File

@@ -0,0 +1,4 @@
export {
type RegisterInput,
ServiceRegistry,
} from "./service-registry.js";

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,599 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { SVC_DISC_CODES, SvcDiscError } from "../../errors/errors.js";
import type {
RegistryEvent,
ServiceDescriptor,
ServiceRegistryEventMap,
ServiceRegistryStorageLike,
} from "../../types/types.js";
import { ServiceRegistry } from "./service-registry.js";
class FakeClock {
public t = 1_000;
public readonly clock = (): number => this.t;
public advance(ms: number): void {
this.t += ms;
}
}
class MemStorage implements ServiceRegistryStorageLike {
public readonly map = new Map<string, Uint8Array>();
async get(key: string): Promise<Uint8Array | undefined> {
return this.map.get(key);
}
async set(key: string, value: Uint8Array): Promise<void> {
this.map.set(key, value);
}
async delete(key: string): Promise<boolean> {
return this.map.delete(key);
}
}
class CapturingBus {
public readonly events: Array<{
topic: keyof ServiceRegistryEventMap;
payload: ServiceDescriptor;
}> = [];
on(): () => void {
return () => undefined;
}
emit<K extends keyof ServiceRegistryEventMap>(
topic: K,
payload: ServiceRegistryEventMap[K]
): void {
this.events.push({ topic, payload });
}
}
const baseInput = {
id: "a1",
name: "api",
address: "10.0.0.1:8080",
status: "healthy" as const,
};
describe("ServiceRegistry", () => {
it("constructs with defaults", () => {
const r = new ServiceRegistry();
expect(r.size).toBe(0);
r.dispose();
});
it("rejects malformed config", () => {
expect(() => new ServiceRegistry({ maxServices: -1 } as never)).toThrow(
SvcDiscError
);
try {
new ServiceRegistry({ ttlMs: 0 } as never);
} catch (err) {
expect((err as SvcDiscError).code).toBe(
SVC_DISC_CODES.INVALID_OPTIONS
);
}
});
it("registers a new service", () => {
const clock = new FakeClock();
const r = new ServiceRegistry({ clock: clock.clock });
const out = r.register(baseInput);
expect(out.id).toBe("a1");
expect(out.registeredAt).toBe(1_000);
expect(out.lastSeen).toBe(1_000);
expect(r.size).toBe(1);
});
it("rejects invalid descriptor input", () => {
const r = new ServiceRegistry();
expect(() =>
r.register({ id: "", name: "x", address: "y" } as never)
).toThrow(SvcDiscError);
});
it("upserts on duplicate by default (refreshed)", () => {
const clock = new FakeClock();
const r = new ServiceRegistry({ clock: clock.clock });
const first = r.register(baseInput);
clock.advance(5_000);
const second = r.register({
...baseInput,
address: "10.0.0.2:9090",
});
expect(second.registeredAt).toBe(first.registeredAt);
expect(second.lastSeen).toBe(6_000);
expect(second.address).toBe("10.0.0.2:9090");
});
it("strict mode rejects duplicates", () => {
const r = new ServiceRegistry();
r.register(baseInput);
try {
r.register(baseInput, { strict: true });
throw new Error("expected throw");
} catch (err) {
expect((err as SvcDiscError).code).toBe(
SVC_DISC_CODES.DUPLICATE_REGISTRATION
);
}
});
it("raises REGISTRY_FULL at capacity", () => {
const r = new ServiceRegistry({ maxServices: 2 });
r.register({ ...baseInput, id: "1" });
r.register({ ...baseInput, id: "2" });
try {
r.register({ ...baseInput, id: "3" });
throw new Error("expected throw");
} catch (err) {
expect((err as SvcDiscError).code).toBe(
SVC_DISC_CODES.REGISTRY_FULL
);
}
});
it("refreshes lastSeen", () => {
const clock = new FakeClock();
const r = new ServiceRegistry({ clock: clock.clock });
r.register(baseInput);
clock.advance(2_000);
const refreshed = r.refresh("a1");
expect(refreshed?.lastSeen).toBe(3_000);
});
it("refresh returns undefined for unknown id", () => {
const r = new ServiceRegistry();
expect(r.refresh("nope")).toBeUndefined();
});
it("deregister removes", () => {
const r = new ServiceRegistry();
r.register(baseInput);
const removed = r.deregister("a1");
expect(removed?.id).toBe("a1");
expect(r.deregister("a1")).toBeUndefined();
expect(r.size).toBe(0);
});
it("get returns and respects TTL", () => {
const clock = new FakeClock();
const r = new ServiceRegistry({ ttlMs: 1_000, clock: clock.clock });
r.register(baseInput);
expect(r.get("a1")?.id).toBe("a1");
clock.advance(2_000);
expect(r.get("a1")).toBeUndefined();
expect(r.get("nope")).toBeUndefined();
});
it("list returns snapshot", () => {
const r = new ServiceRegistry();
r.register({ ...baseInput, id: "1" });
r.register({ ...baseInput, id: "2" });
expect(
r
.list()
.map((s) => s.id)
.sort()
).toEqual(["1", "2"]);
});
it("query filters by every dimension", () => {
const r = new ServiceRegistry();
r.register({
...baseInput,
id: "1",
region: "us",
tags: ["v1", "edge"],
});
r.register({
...baseInput,
id: "2",
region: "eu",
tags: ["v1"],
status: "unhealthy",
});
r.register({ ...baseInput, id: "3", name: "worker", region: "us" });
expect(r.query().length).toBe(3);
expect(
r
.query({ name: "api" })
.map((s) => s.id)
.sort()
).toEqual(["1", "2"]);
expect(
r
.query({ status: "healthy" })
.map((s) => s.id)
.sort()
).toEqual(["1", "3"]);
expect(
r
.query({ region: "us" })
.map((s) => s.id)
.sort()
).toEqual(["1", "3"]);
expect(r.query({ tags: ["v1", "edge"] }).map((s) => s.id)).toEqual([
"1",
]);
expect(
r
.query({ tags: ["v1"] })
.map((s) => s.id)
.sort()
).toEqual(["1", "2"]);
expect(r.query({ tags: ["missing"] })).toEqual([]);
});
it("query rejects bad shape", () => {
const r = new ServiceRegistry();
try {
r.query({ name: "" } as never);
throw new Error("expected throw");
} catch (err) {
expect((err as SvcDiscError).code).toBe(
SVC_DISC_CODES.QUERY_INVALID
);
}
});
it("prune removes expired entries and reports count", () => {
const clock = new FakeClock();
const r = new ServiceRegistry({ ttlMs: 1_000, clock: clock.clock });
r.register({ ...baseInput, id: "1" });
r.register({ ...baseInput, id: "2" });
clock.advance(2_000);
expect(r.prune()).toBe(2);
expect(r.size).toBe(0);
});
it("emits to a configured bus", () => {
const bus = new CapturingBus();
const clock = new FakeClock();
const r = new ServiceRegistry({
clock: clock.clock,
ttlMs: 1_000,
bus,
});
r.register(baseInput);
r.register(baseInput); // refresh -> still 'registered'
r.deregister("a1");
r.register({ ...baseInput, id: "exp" });
clock.advance(2_000);
r.prune();
const topics = bus.events.map((e) => e.topic);
expect(topics).toEqual([
"service:registered",
"service:registered",
"service:deregistered",
"service:registered",
"service:expired",
]);
});
it("watch yields events that match the query", async () => {
const r = new ServiceRegistry();
const events: RegistryEvent[] = [];
const iter = r.watch({ name: "api" });
const consumer = (async () => {
for await (const ev of iter) {
events.push(ev);
if (events.length === 2) break;
}
})();
// Allow microtask boundary so the consumer attaches.
await Promise.resolve();
r.register(baseInput);
r.register({ ...baseInput, id: "other", name: "worker" }); // filtered out
r.deregister("a1");
await consumer;
expect(events.map((e) => e.type)).toEqual([
"registered",
"deregistered",
]);
});
it("watch buffers events before consumer attaches", async () => {
const r = new ServiceRegistry();
const iter = r.watch();
r.register(baseInput);
r.register({ ...baseInput, id: "b" });
const first = await iter.next();
const second = await iter.next();
expect(first.value?.type).toBe("registered");
expect(second.value?.type).toBe("registered");
await iter.return?.();
});
it("watch terminates on AbortSignal abort", async () => {
const r = new ServiceRegistry();
const ac = new AbortController();
const iter = r.watch({}, ac.signal);
const result = iter.next();
ac.abort();
const step = await result;
expect(step.done).toBe(true);
});
it("watch with already-aborted signal yields done immediately", async () => {
const r = new ServiceRegistry();
const ac = new AbortController();
ac.abort();
const iter = r.watch({}, ac.signal);
const step = await iter.next();
expect(step.done).toBe(true);
});
it("watch.return cleans up watcher and detaches signal", async () => {
const r = new ServiceRegistry();
const ac = new AbortController();
const iter = r.watch({}, ac.signal);
const ret = await iter.return?.();
expect(ret?.done).toBe(true);
// Subsequent next yields done.
const step = await iter.next();
expect(step.done).toBe(true);
// Calling return again must be safe (already-closed guard).
const again = await iter.return?.();
expect(again?.done).toBe(true);
});
it("dispose after return is a no-op for the watcher", async () => {
const r = new ServiceRegistry();
const iter = r.watch();
await iter.return?.();
// Disposing the registry must still close cleanly even if the
// watcher was already closed via return().
r.dispose();
});
it("gaugeSize reports raw map size for OTel pull", () => {
const r = new ServiceRegistry();
expect(r.gaugeSize()).toBe(0);
r.register(baseInput);
expect(r.gaugeSize()).toBe(1);
});
it("preserves version, region, tags, and metadata on register", () => {
const r = new ServiceRegistry();
const out = r.register({
...baseInput,
version: "1.2.3",
region: "us-east-1",
tags: ["edge", "v1"],
metadata: { weight: 5 },
});
expect(out.version).toBe("1.2.3");
expect(out.region).toBe("us-east-1");
expect(out.tags).toEqual(["edge", "v1"]);
expect(out.metadata).toEqual({ weight: 5 });
});
it("watch terminates when registry is disposed", async () => {
const r = new ServiceRegistry();
const iter = r.watch();
const pending = iter.next();
r.dispose();
const step = await pending;
expect(step.done).toBe(true);
});
it("watch rejects invalid query", () => {
const r = new ServiceRegistry();
try {
r.watch({ status: "weird" } as never);
throw new Error("expected throw");
} catch (err) {
expect((err as SvcDiscError).code).toBe(
SVC_DISC_CODES.QUERY_INVALID
);
}
});
it("expired events fire when reads trigger pruning", async () => {
const clock = new FakeClock();
const r = new ServiceRegistry({ ttlMs: 1_000, clock: clock.clock });
const seen: string[] = [];
const iter = r.watch();
(async () => {
for await (const ev of iter) {
seen.push(ev.type);
if (seen.length === 2) break;
}
})();
await Promise.resolve();
r.register(baseInput);
clock.advance(2_000);
r.list(); // triggers prune
// give the consumer a tick to drain
await new Promise((res) => setTimeout(res, 5));
expect(seen).toContain("expired");
});
it("register on disposed registry throws", () => {
const r = new ServiceRegistry();
r.dispose();
try {
r.register(baseInput);
throw new Error("expected throw");
} catch (err) {
expect((err as SvcDiscError).code).toBe(
SVC_DISC_CODES.INVALID_OPTIONS
);
}
});
it("double dispose is a no-op", () => {
const r = new ServiceRegistry();
r.dispose();
r.dispose();
});
it("flush + load persist a snapshot", async () => {
const storage = new MemStorage();
const r1 = new ServiceRegistry({
storage,
persistKey: "registry:test",
});
r1.register(baseInput);
r1.register({ ...baseInput, id: "b" });
await r1.flush();
const r2 = new ServiceRegistry({
storage,
persistKey: "registry:test",
});
await r2.load();
expect(r2.size).toBe(2);
expect(r2.get("a1")?.id).toBe("a1");
});
it("flush is a no-op without persist config", async () => {
const r = new ServiceRegistry();
await r.flush();
await r.load();
});
it("load no-ops when no snapshot is present", async () => {
const storage = new MemStorage();
const r = new ServiceRegistry({
storage,
persistKey: "registry:absent",
});
await r.load();
expect(r.size).toBe(0);
});
it("load rejects corrupted JSON", async () => {
const storage = new MemStorage();
storage.map.set("registry:bad", new TextEncoder().encode("not-json"));
const r = new ServiceRegistry({
storage,
persistKey: "registry:bad",
});
try {
await r.load();
throw new Error("expected throw");
} catch (err) {
expect((err as SvcDiscError).code).toBe(
SVC_DISC_CODES.INVALID_OPTIONS
);
}
});
it("load rejects wrong shape", async () => {
const storage = new MemStorage();
storage.map.set(
"registry:shape",
new TextEncoder().encode(JSON.stringify({ not: "array" }))
);
const r = new ServiceRegistry({
storage,
persistKey: "registry:shape",
});
try {
await r.load();
throw new Error("expected throw");
} catch (err) {
expect((err as SvcDiscError).code).toBe(
SVC_DISC_CODES.INVALID_OPTIONS
);
}
});
it("load filters out invalid descriptor entries", async () => {
const storage = new MemStorage();
const clock = new FakeClock();
storage.map.set(
"registry:mixed",
new TextEncoder().encode(
JSON.stringify([
{
id: "ok",
name: "n",
address: "a",
status: "healthy",
registeredAt: clock.t,
lastSeen: clock.t,
},
{ id: "bad" }, // missing fields
null,
"string",
])
)
);
const r = new ServiceRegistry({
storage,
persistKey: "registry:mixed",
clock: clock.clock,
});
await r.load();
expect(r.size).toBe(1);
expect(r.get("ok")?.id).toBe("ok");
});
});
describe("ServiceRegistry property tests", () => {
it("TTL expiry is monotonic in clock time", () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 10_000 }),
fc.integer({ min: 0, max: 100_000 }),
(ttlMs, advance) => {
const clock = new FakeClock();
const r = new ServiceRegistry({
ttlMs,
clock: clock.clock,
});
r.register(baseInput);
clock.advance(advance);
const alive = r.get("a1");
if (advance > ttlMs) {
return alive === undefined;
}
return alive?.id === "a1";
}
),
{ numRuns: 50 }
);
});
it("query is deterministic for a given snapshot", () => {
fc.assert(
fc.property(
fc.array(
fc.record({
id: fc.string({ minLength: 1, maxLength: 8 }),
name: fc.constantFrom("api", "worker"),
region: fc.constantFrom("us", "eu"),
}),
{ maxLength: 20 }
),
fc.constantFrom("api", "worker"),
fc.constantFrom("us", "eu"),
(entries, name, region) => {
const r = new ServiceRegistry();
const seen = new Set<string>();
for (const e of entries) {
if (seen.has(e.id)) continue;
seen.add(e.id);
r.register({
...baseInput,
id: e.id,
name: e.name,
region: e.region,
});
}
const a = r.query({ name, region }).map((s) => s.id);
const b = r.query({ name, region }).map((s) => s.id);
return (
a.length === b.length && a.every((v, i) => v === b[i])
);
}
),
{ numRuns: 30 }
);
});
});

View File

@@ -0,0 +1,506 @@
import type { Clock, EventBusLike } from "@suaveplan/types";
import { SVC_DISC_CODES, SvcDiscError } from "../../errors/errors.js";
import {
bindActiveServicesGauge,
recordQuery,
recordQueryDuration,
recordRegistration,
} from "../../otel/otel.js";
import type {
RegistryEvent,
ServiceDescriptor,
ServiceQuery,
ServiceRegistryConfig,
ServiceRegistryEventMap,
ServiceRegistryStorageLike,
ServiceStatus,
} from "../../types/types.js";
import {
RegistrationInputSchema,
ServiceQuerySchema,
ServiceRegistryConfigSchema,
} from "../../types/types.schemas.js";
const defaultClock: Clock = () => Date.now();
/** Input accepted by `register()`: everything except registry-managed timestamps. */
export interface RegisterInput {
readonly id: string;
readonly name: string;
readonly address: string;
readonly status?: ServiceStatus;
readonly version?: string;
readonly region?: string;
readonly tags?: readonly string[];
readonly metadata?: Readonly<Record<string, unknown>>;
}
interface WatcherSlot {
readonly query: ServiceQuery;
push(event: RegistryEvent): void;
close(): void;
}
/**
* Universal `ServiceRegistry`.
*
* In-memory by default; optionally backed by an `AbstractStorage`-shaped
* `ServiceRegistryStorageLike` for cross-restart persistence and an
* `EventBusLike<ServiceRegistryEventMap>` for fan-out to a shared bus.
*
* - `register()` is upsert by default: a repeat call for the same `id`
* refreshes `lastSeen` and re-validates the descriptor. Use
* `register(input, { strict: true })` to reject duplicates with
* `SVC_DISC.DUPLICATE_REGISTRATION`.
* - `query()` returns a snapshot filtered by name/status/region/tag-superset.
* - `watch()` returns an `AsyncIterableIterator<RegistryEvent>` that yields
* only events matching the query. The iterator terminates when the optional
* `AbortSignal` aborts, when the registry is `dispose()`d, or when the
* consumer breaks the loop.
* - TTL pruning happens on every read (`query()`, `list()`, `get()`) and on
* the explicit `prune()` entry point. Pruned entries emit `expired`.
*/
export class ServiceRegistry {
private readonly services: Map<string, ServiceDescriptor> = new Map();
private readonly maxServices: number;
private readonly ttlMs: number;
private readonly clock: Clock;
private readonly storage: ServiceRegistryStorageLike | undefined;
private readonly persistKey: string | undefined;
private readonly bus: EventBusLike<ServiceRegistryEventMap> | undefined;
private readonly watchers: Set<WatcherSlot> = new Set();
private readonly disposeGauge: () => void;
private disposed = false;
public constructor(config: ServiceRegistryConfig = {}) {
const parsed = ServiceRegistryConfigSchema.safeParse(config);
if (!parsed.success) {
throw new SvcDiscError(
SVC_DISC_CODES.INVALID_OPTIONS,
"service-registry.invalid-options",
{ cause: parsed.error }
);
}
this.maxServices = parsed.data.maxServices;
this.ttlMs = parsed.data.ttlMs;
this.clock = (parsed.data.clock as Clock | undefined) ?? defaultClock;
this.storage = parsed.data.storage as
| ServiceRegistryStorageLike
| undefined;
this.persistKey = parsed.data.persistKey;
this.bus = parsed.data.bus as
| EventBusLike<ServiceRegistryEventMap>
| undefined;
this.disposeGauge = bindActiveServicesGauge(this.gaugeSize);
}
/**
* Untouched-by-prune snapshot used as the OTel gauge source. Exposed as a
* bound method so the OTel callback can capture it without holding a
* reference to the registry instance.
*/
public readonly gaugeSize = (): number => this.services.size;
/**
* Register or refresh a service instance.
*
* @throws {SvcDiscError} `INVALID_OPTIONS` if the descriptor fails Zod parse.
* @throws {SvcDiscError} `REGISTRY_FULL` if at capacity and the id is new.
* @throws {SvcDiscError} `DUPLICATE_REGISTRATION` only when `strict: true`
* and the id is already present.
*/
public register(
input: RegisterInput,
options: { readonly strict?: boolean } = {}
): ServiceDescriptor {
if (this.disposed) {
throw new SvcDiscError(
SVC_DISC_CODES.INVALID_OPTIONS,
"service-registry.disposed"
);
}
const parsed = RegistrationInputSchema.safeParse(input);
if (!parsed.success) {
recordRegistration("rejected");
throw new SvcDiscError(
SVC_DISC_CODES.INVALID_OPTIONS,
"service-registry.invalid-descriptor",
{ cause: parsed.error }
);
}
const data = parsed.data;
const now = this.clock();
const existing = this.services.get(data.id);
if (existing) {
if (options.strict) {
recordRegistration("rejected");
throw new SvcDiscError(
SVC_DISC_CODES.DUPLICATE_REGISTRATION,
"service-registry.duplicate-registration"
);
}
const refreshed = buildDescriptor(data, existing.registeredAt, now);
this.services.set(data.id, refreshed);
recordRegistration("refreshed");
this.emit({ type: "registered", service: refreshed });
return refreshed;
}
if (this.services.size >= this.maxServices) {
recordRegistration("rejected");
throw new SvcDiscError(
SVC_DISC_CODES.REGISTRY_FULL,
"service-registry.registry-full"
);
}
const next = buildDescriptor(data, now, now);
this.services.set(data.id, next);
recordRegistration("registered");
this.emit({ type: "registered", service: next });
return next;
}
/**
* Update `lastSeen` for an existing service. No-op (and returns `undefined`)
* if the id is unknown or expired.
*/
public refresh(id: string): ServiceDescriptor | undefined {
this.pruneExpired();
const current = this.services.get(id);
if (!current) return undefined;
const updated = buildDescriptor(
current,
current.registeredAt,
this.clock()
);
this.services.set(id, updated);
return updated;
}
/**
* Deregister a service. Returns the removed descriptor or `undefined` if
* not found.
*/
public deregister(id: string): ServiceDescriptor | undefined {
const removed = this.services.get(id);
if (!removed) return undefined;
this.services.delete(id);
this.emit({ type: "deregistered", service: removed });
return removed;
}
/** Get one service by id, or `undefined` if not found / expired. */
public get(id: string): ServiceDescriptor | undefined {
this.pruneExpired();
return this.services.get(id);
}
/** Return every non-expired descriptor (snapshot). */
public list(): ServiceDescriptor[] {
this.pruneExpired();
return Array.from(this.services.values());
}
/** Snapshot count of services known to the registry (post-prune). */
public get size(): number {
this.pruneExpired();
return this.services.size;
}
/**
* Snapshot-query the registry. Throws `QUERY_INVALID` if `query` fails Zod
* parse.
*/
public query(query: ServiceQuery = {}): ServiceDescriptor[] {
const parsed = ServiceQuerySchema.safeParse(query);
if (!parsed.success) {
throw new SvcDiscError(
SVC_DISC_CODES.QUERY_INVALID,
"service-registry.query-invalid",
{ cause: parsed.error }
);
}
const start = this.clock();
recordQuery();
this.pruneExpired();
const out: ServiceDescriptor[] = [];
for (const svc of this.services.values()) {
if (matches(svc, normalizeQuery(parsed.data))) out.push(svc);
}
recordQueryDuration(this.clock() - start);
return out;
}
/**
* Explicit prune of expired entries. Returns the count removed. Each pruned
* service is announced as `expired` on the bus and to active watchers.
*/
public prune(): number {
return this.pruneExpired();
}
/**
* Stream registry events matching `query`. The iterator terminates when:
*
* - `signal` aborts (yields nothing further; loop exits cleanly).
* - the registry is `dispose()`d.
* - the consumer breaks the for-await loop (cleanup runs in `return()`).
*
* @throws {SvcDiscError} `QUERY_INVALID` if `query` fails Zod parse.
*/
public watch(
query: ServiceQuery = {},
signal?: AbortSignal
): AsyncIterableIterator<RegistryEvent> {
const parsed = ServiceQuerySchema.safeParse(query);
if (!parsed.success) {
throw new SvcDiscError(
SVC_DISC_CODES.QUERY_INVALID,
"service-registry.query-invalid",
{ cause: parsed.error }
);
}
return this.createWatchIterator(normalizeQuery(parsed.data), signal);
}
/**
* Persist a JSON snapshot through the configured `storage` (no-op when
* persistence is not configured).
*/
public async flush(signal?: AbortSignal): Promise<void> {
if (!this.storage || !this.persistKey) return;
this.pruneExpired();
const payload = JSON.stringify(Array.from(this.services.values()));
await this.storage.set(
this.persistKey,
new TextEncoder().encode(payload),
signal
);
}
/**
* Restore the registry from a previously-`flush()`ed snapshot. Silently
* no-ops when persistence is not configured or no snapshot exists. Invalid
* payloads are surfaced as `INVALID_OPTIONS` rather than silently ignored.
*/
public async load(signal?: AbortSignal): Promise<void> {
if (!this.storage || !this.persistKey) return;
const bytes = await this.storage.get(this.persistKey, signal);
if (!bytes) return;
let raw: unknown;
try {
raw = JSON.parse(new TextDecoder().decode(bytes));
} catch (cause) {
throw new SvcDiscError(
SVC_DISC_CODES.INVALID_OPTIONS,
"service-registry.persisted-corrupted",
{ cause }
);
}
if (!Array.isArray(raw)) {
throw new SvcDiscError(
SVC_DISC_CODES.INVALID_OPTIONS,
"service-registry.persisted-shape"
);
}
this.services.clear();
for (const entry of raw) {
if (isValidDescriptor(entry)) this.services.set(entry.id, entry);
}
}
/**
* Tear down the registry. All active watchers terminate cleanly and the
* OTel gauge is unbound. After `dispose()`, calls to `register()` throw.
*/
public dispose(): void {
if (this.disposed) return;
this.disposed = true;
for (const w of this.watchers) w.close();
this.watchers.clear();
this.disposeGauge();
}
private emit(event: RegistryEvent): void {
// Fan-out to bus first (observers must not throw).
if (this.bus) {
const topic: keyof ServiceRegistryEventMap =
event.type === "registered"
? "service:registered"
: event.type === "deregistered"
? "service:deregistered"
: "service:expired";
this.bus.emit(topic, event.service);
}
// Then push to watchers (they apply per-watcher query filter).
for (const w of this.watchers) {
if (matches(event.service, w.query)) w.push(event);
}
}
private pruneExpired(): number {
const now = this.clock();
let removed = 0;
for (const [id, svc] of this.services) {
if (now - svc.lastSeen > this.ttlMs) {
this.services.delete(id);
removed += 1;
this.emit({ type: "expired", service: svc });
}
}
return removed;
}
private createWatchIterator(
query: ServiceQuery,
signal: AbortSignal | undefined
): AsyncIterableIterator<RegistryEvent> {
const buffer: RegistryEvent[] = [];
let resolveNext:
| ((step: IteratorResult<RegistryEvent>) => void)
| null = null;
let closed = false;
const slot: WatcherSlot = {
query,
push: (event: RegistryEvent): void => {
if (resolveNext) {
const r = resolveNext;
resolveNext = null;
r({ value: event, done: false });
} else {
buffer.push(event);
}
},
close: (): void => {
if (closed) return;
closed = true;
if (resolveNext) {
const r = resolveNext;
resolveNext = null;
r({ value: undefined, done: true });
}
},
};
this.watchers.add(slot);
const onAbort = (): void => {
slot.close();
this.watchers.delete(slot);
};
if (signal) {
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener("abort", onAbort, { once: true });
}
}
const iter: AsyncIterableIterator<RegistryEvent> = {
[Symbol.asyncIterator]() {
return this;
},
next: (): Promise<IteratorResult<RegistryEvent>> => {
if (buffer.length > 0) {
const value = buffer.shift() as RegistryEvent;
return Promise.resolve({ value, done: false });
}
if (closed) {
return Promise.resolve({ value: undefined, done: true });
}
return new Promise<IteratorResult<RegistryEvent>>((resolve) => {
resolveNext = resolve;
});
},
return: (): Promise<IteratorResult<RegistryEvent>> => {
slot.close();
this.watchers.delete(slot);
if (signal) signal.removeEventListener("abort", onAbort);
return Promise.resolve({ value: undefined, done: true });
},
};
return iter;
}
}
/**
* Build a `ServiceDescriptor` from validated input + registry timestamps,
* omitting optional fields that came through as `undefined`. This keeps the
* stored shape compatible with `exactOptionalPropertyTypes: true`.
*/
function buildDescriptor(
data: {
id: string;
name: string;
address: string;
status: ServiceStatus;
version?: string | undefined;
region?: string | undefined;
tags?: readonly string[] | undefined;
metadata?: Readonly<Record<string, unknown>> | undefined;
},
registeredAt: number,
lastSeen: number
): ServiceDescriptor {
const out: {
-readonly [K in keyof ServiceDescriptor]: ServiceDescriptor[K];
} = {
id: data.id,
name: data.name,
address: data.address,
status: data.status,
registeredAt,
lastSeen,
};
if (data.version !== undefined) out.version = data.version;
if (data.region !== undefined) out.region = data.region;
if (data.tags !== undefined) out.tags = data.tags;
if (data.metadata !== undefined) out.metadata = data.metadata;
return out;
}
function normalizeQuery(data: {
name?: string | undefined;
status?: ServiceStatus | undefined;
region?: string | undefined;
tags?: string[] | undefined;
}): ServiceQuery {
const q: {
-readonly [K in keyof ServiceQuery]: ServiceQuery[K];
} = {};
if (data.name !== undefined) q.name = data.name;
if (data.status !== undefined) q.status = data.status;
if (data.region !== undefined) q.region = data.region;
if (data.tags !== undefined) q.tags = data.tags;
return q;
}
function matches(svc: ServiceDescriptor, q: ServiceQuery): boolean {
if (q.name !== undefined && svc.name !== q.name) return false;
if (q.status !== undefined && svc.status !== q.status) return false;
if (q.region !== undefined && svc.region !== q.region) return false;
if (q.tags && q.tags.length > 0) {
const have = svc.tags ?? [];
for (const t of q.tags) if (!have.includes(t)) return false;
}
return true;
}
function isValidDescriptor(value: unknown): value is ServiceDescriptor {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v["id"] === "string" &&
typeof v["name"] === "string" &&
typeof v["address"] === "string" &&
typeof v["status"] === "string" &&
typeof v["registeredAt"] === "number" &&
typeof v["lastSeen"] === "number"
);
}

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,26 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SVC_DISC_CODES, SvcDiscError } from "./errors.js";
describe("SvcDiscError", () => {
it("exposes all four codes", () => {
expect(SVC_DISC_CODES.DUPLICATE_REGISTRATION).toBe(
"SVC_DISC.DUPLICATE_REGISTRATION"
);
expect(SVC_DISC_CODES.QUERY_INVALID).toBe("SVC_DISC.QUERY_INVALID");
expect(SVC_DISC_CODES.REGISTRY_FULL).toBe("SVC_DISC.REGISTRY_FULL");
expect(SVC_DISC_CODES.INVALID_OPTIONS).toBe("SVC_DISC.INVALID_OPTIONS");
});
it("constructs with code + messageKey + cause", () => {
const cause = new Error("upstream");
const err = new SvcDiscError(
SVC_DISC_CODES.QUERY_INVALID,
"test.message",
{ cause }
);
expect(err).toBeInstanceOf(SvcDiscError);
expect(err.code).toBe(SVC_DISC_CODES.QUERY_INVALID);
expect(err.name).toBe("SvcDiscError");
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,38 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Error codes emitted by `@suaveplan/service-discovery`.
*
* - `DUPLICATE_REGISTRATION` — `register()` called with an id already present.
* - `QUERY_INVALID` — supplied query failed Zod parse.
* - `REGISTRY_FULL` — capacity (`maxServices`) reached.
* - `INVALID_OPTIONS` — caller supplied an option struct that failed Zod parse.
*/
export const SVC_DISC_CODES = defineCodes("SVC_DISC", [
"DUPLICATE_REGISTRATION",
"QUERY_INVALID",
"REGISTRY_FULL",
"INVALID_OPTIONS",
] as const);
export type SvcDiscErrorCode =
(typeof SVC_DISC_CODES)[keyof typeof SVC_DISC_CODES];
/**
* Structured error class for `@suaveplan/service-discovery`. Every public
* boundary throws this on failure; consumers branch on `error.code`.
*/
export class SvcDiscError extends SuaveplanError<SvcDiscErrorCode> {
public constructor(
code: SvcDiscErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "SvcDiscError";
}
}

View File

@@ -0,0 +1,40 @@
// Core
export type { RegisterInput } from "./core/service-registry/service-registry.js";
export { ServiceRegistry } from "./core/service-registry/service-registry.js";
// Errors
export {
SVC_DISC_CODES,
SvcDiscError,
type SvcDiscErrorCode,
} from "./errors/errors.js";
// OTel
export {
bindActiveServicesGauge,
type RegistrationOutcome,
recordQuery,
recordQueryDuration,
recordRegistration,
} from "./otel/otel.js";
// Types
export type {
RegistryEvent,
ServiceDescriptor,
ServiceQuery,
ServiceRegistryConfig,
ServiceRegistryEventMap,
ServiceRegistryStorageLike,
ServiceStatus,
} from "./types/types.js";
// Schemas
export {
RegistrationInputSchema,
ServiceDescriptorSchema,
ServiceQuerySchema,
ServiceRegistryConfigSchema,
ServiceStatusSchema,
} from "./types/types.schemas.js";

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,50 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
bindActiveServicesGauge,
makeGaugeCallback,
recordQuery,
recordQueryDuration,
recordRegistration,
} from "./otel.js";
describe("otel instruments", () => {
it("records every registration outcome", () => {
// Smoke: must not throw. Metric backend defaults to a no-op meter
// provider unless an OTel SDK is wired by the host.
recordRegistration("registered");
recordRegistration("refreshed");
recordRegistration("rejected");
recordQuery();
});
it("clamps negative durations to zero", () => {
recordQueryDuration(-10);
recordQueryDuration(0);
recordQueryDuration(1.5);
});
it("binds and unbinds the active-services gauge", () => {
let calls = 0;
const dispose = bindActiveServicesGauge(() => {
calls += 1;
return 42;
});
expect(typeof dispose).toBe("function");
// The observable callback only fires on scrape, which the no-op
// meter does not schedule — we cannot assert `calls > 0` without
// a wired SDK. Disposer must be safe to call regardless.
dispose();
expect(calls).toBeGreaterThanOrEqual(0);
});
it("gauge callback observes the size on scrape", () => {
const cb = makeGaugeCallback(() => 7);
let observed = -1;
cb({
observe(v) {
observed = v;
},
});
expect(observed).toBe(7);
});
});

View File

@@ -0,0 +1,97 @@
import { metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/service-discovery";
const meter = metrics.getMeter(METER_NAME);
/**
* Counter incremented on every `register()` attempt.
* Labelled by `{outcome}` — one of `"registered" | "refreshed" | "rejected"`.
*/
const registrationsCounter = meter.createCounter(
"svc_disc.registrations_total",
{
description:
"Total registration attempts, by outcome (registered/refreshed/rejected).",
}
);
/**
* Counter incremented on every `query()` call.
*/
const queriesCounter = meter.createCounter("svc_disc.queries_total", {
description: "Total query calls against the registry.",
});
/**
* Histogram of query durations in milliseconds.
*/
const queryDurationHistogram = meter.createHistogram(
"svc_disc.query_duration_ms",
{
description: "Wall-clock duration of a query in milliseconds.",
unit: "ms",
}
);
/**
* Observable gauge of currently-active (non-expired) services. The registry
* registers its `size` callback once at construction; the meter pulls on
* scrape.
*/
const activeServicesGauge = meter.createObservableGauge(
"svc_disc.active_services",
{
description: "Currently-tracked services in the registry.",
}
);
/** Outcome variants for `svc_disc.registrations_total{outcome}`. */
export type RegistrationOutcome = "registered" | "refreshed" | "rejected";
/**
* Record a registration outcome.
*/
export function recordRegistration(outcome: RegistrationOutcome): void {
registrationsCounter.add(1, { outcome });
}
/**
* Record one query call.
*/
export function recordQuery(): void {
queriesCounter.add(1);
}
/**
* Record the wall-clock duration of a query.
*/
export function recordQueryDuration(ms: number): void {
queryDurationHistogram.record(Math.max(0, ms));
}
/**
* Bind a size-callback to the observable gauge. Returns a disposer that
* unbinds the callback. Pass the registry's `size` getter; the meter will
* pull on scrape.
*/
export function bindActiveServicesGauge(getSize: () => number): () => void {
const callback = makeGaugeCallback(getSize);
activeServicesGauge.addCallback(callback);
return () => {
activeServicesGauge.removeCallback(callback);
};
}
/**
* Build the callback that the OTel observable-gauge invokes on scrape. Exposed
* so tests can drive the callback against a stub observable without spinning
* up a real OTel SDK.
*/
export function makeGaugeCallback(
getSize: () => number
): (observable: { observe(value: number): void }) => void {
return (observable) => {
observable.observe(getSize());
};
}

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.

View File

@@ -0,0 +1,54 @@
import z from "zod";
/**
* Zod boundary schemas. Schemas validate the *shape* of data; callable
* contracts (`ServiceRegistryStorageLike`, `EventBusLike`, `Clock`) are
* checked structurally at the type level and never coerced through Zod.
*/
export const ServiceStatusSchema = z.enum([
"healthy",
"unhealthy",
"starting",
"stopping",
]);
const RegistrationInputSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
address: z.string().min(1),
status: ServiceStatusSchema.default("healthy"),
version: z.string().min(1).optional(),
region: z.string().min(1).optional(),
tags: z.array(z.string().min(1)).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
.strict();
export { RegistrationInputSchema };
export const ServiceDescriptorSchema = RegistrationInputSchema.extend({
registeredAt: z.number().int().nonnegative(),
lastSeen: z.number().int().nonnegative(),
}).strict();
export const ServiceQuerySchema = z
.object({
name: z.string().min(1).optional(),
status: ServiceStatusSchema.optional(),
region: z.string().min(1).optional(),
tags: z.array(z.string().min(1)).optional(),
})
.strict();
export const ServiceRegistryConfigSchema = z
.object({
maxServices: z.number().int().positive().default(10_000),
ttlMs: z.number().int().positive().default(60_000),
clock: z.unknown().optional(),
storage: z.unknown().optional(),
persistKey: z.string().min(1).optional(),
bus: z.unknown().optional(),
})
.strict();

View File

@@ -0,0 +1,108 @@
import type { Clock, EventBusLike } from "@suaveplan/types";
/**
* Health/lifecycle status of a registered service instance.
*
* - `healthy` — service is registered and serving traffic.
* - `unhealthy` — service is registered but failing or unreachable.
* - `starting` — service is registering but not yet ready.
* - `stopping` — service is draining connections and will deregister soon.
*/
export type ServiceStatus = "healthy" | "unhealthy" | "starting" | "stopping";
/**
* A single service-instance descriptor stored in the registry.
*
* Field ordering is intentional: the four required identity fields come first,
* followed by optional routing/metadata fields, and finally the registry-managed
* timestamps (`registeredAt`, `lastSeen`).
*/
export interface ServiceDescriptor {
/** Unique service-instance id (caller-supplied). */
readonly id: string;
/** Logical service name; multiple instances may share the same name. */
readonly name: string;
/** Network address (host[:port]) or opaque endpoint identifier. */
readonly address: string;
/** Service status. */
readonly status: ServiceStatus;
/** Optional service version (semver or free-form). */
readonly version?: string;
/** Optional region/zone for region-aware routing. */
readonly region?: string;
/** Optional tags for query filtering. */
readonly tags?: readonly string[];
/** Optional opaque metadata bag. */
readonly metadata?: Readonly<Record<string, unknown>>;
/** Wall-clock ms of first registration. Set by the registry. */
readonly registeredAt: number;
/** Wall-clock ms of the most recent heartbeat/refresh. Set by the registry. */
readonly lastSeen: number;
}
/**
* Query filter for {@link ServiceRegistry.query} and {@link ServiceRegistry.watch}.
*
* Every supplied field acts as an AND-conjoined filter; unspecified fields
* are not constrained.
*/
export interface ServiceQuery {
readonly name?: string;
readonly status?: ServiceStatus;
readonly region?: string;
/** Match instances whose tag set is a superset of every supplied tag. */
readonly tags?: readonly string[];
}
/**
* Discriminated-union event emitted by the registry watch stream and bus.
*/
export type RegistryEvent =
| { readonly type: "registered"; readonly service: ServiceDescriptor }
| { readonly type: "deregistered"; readonly service: ServiceDescriptor }
| { readonly type: "expired"; readonly service: ServiceDescriptor };
/**
* Optional persistent backing for the registry. Matches the subset of
* `@suaveplan/storage` `AbstractStorage` the registry needs without taking
* a hard runtime dep on it.
*/
export interface ServiceRegistryStorageLike {
get(key: string, signal?: AbortSignal): Promise<Uint8Array | undefined>;
set(key: string, value: Uint8Array, signal?: AbortSignal): Promise<void>;
delete(key: string, signal?: AbortSignal): Promise<boolean>;
}
/**
* Bus topics emitted by the registry when an `EventBusLike` is configured.
*/
export type ServiceRegistryEventMap = {
"service:registered": ServiceDescriptor;
"service:deregistered": ServiceDescriptor;
"service:expired": ServiceDescriptor;
};
/**
* Configuration for {@link ServiceRegistry}.
*/
export interface ServiceRegistryConfig {
/** Soft cap on number of stored services. Defaults to 10_000. */
readonly maxServices?: number;
/**
* Time-to-live for a service instance in milliseconds. After this many ms
* without a `refresh()` or `register()` update, the entry is considered
* expired and pruned on next read or watch tick. Defaults to 60_000.
*/
readonly ttlMs?: number;
/** Injectable wall clock; defaults to `Date.now`. */
readonly clock?: Clock;
/** Optional storage backend for cross-restart persistence. */
readonly storage?: ServiceRegistryStorageLike;
/** Storage key prefix (only relevant when `storage` is supplied). */
readonly persistKey?: string;
/**
* Optional event bus for emitting `service:registered`/`:deregistered`/`:expired`
* topics.
*/
readonly bus?: EventBusLike<ServiceRegistryEventMap>;
}

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("service-discovery dep-graph", () => {
it("must not have circular dependencies", async () => {
const mod = await import("../src/index.js");
expect(mod).toBeDefined();
expect(mod.ServiceRegistry).toBeDefined();
expect(mod.SVC_DISC_CODES).toBeDefined();
expect(mod.SvcDiscError).toBeDefined();
expect(mod.recordRegistration).toBeDefined();
expect(mod.ServiceRegistryConfigSchema).toBeDefined();
});
});

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});