Initial Commit
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# `connection-registry` — multi-index lookup for active connections
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Every plugin endpoint that opens a WebSocket connection registers it with a
|
||||
host-owned `ConnectionRegistry`. The registry is the lookup surface every
|
||||
other piece of the engine consumes: the subscription bus iterates
|
||||
connections by `(topic, tenant)`, the per-tenant cap counts by `tenant`,
|
||||
the heartbeat loop walks the registry to find stale connections. Keeping
|
||||
the contract here lets future broker-backed registries (Redis, SurrealDB)
|
||||
swap in without touching plugin code.
|
||||
|
||||
This module ships the abstract `ConnectionRegistry` interface AND a
|
||||
single-host in-memory implementation. The in-memory registry doubles as
|
||||
the default for the conformance harness and is what every test in the
|
||||
Stage-1 contract depends on.
|
||||
|
||||
## Features
|
||||
|
||||
- `ConnectionRegistry` interface — `add`, `remove`, `lookupByTenant`,
|
||||
`lookupByTopic`, `countByTenant`.
|
||||
- `InMemoryConnectionRegistry` — O(1) `add` / `remove` / lookups via three
|
||||
internal maps; idempotent `add` / `remove`; topic bindings dropped
|
||||
automatically when a connection is removed.
|
||||
- `recordSubscription(conn, topic, tenant)` and `dropSubscription(...)`
|
||||
hooks the subscription bus uses to keep the `(topic, tenant)` index in
|
||||
sync.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { InMemoryConnectionRegistry } from "@suaveplan/api-ws/connection-registry";
|
||||
|
||||
const registry = new InMemoryConnectionRegistry();
|
||||
registry.add(connection);
|
||||
const peersForTenant = registry.lookupByTenant(connection.tenantId);
|
||||
registry.remove(connection);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
InMemoryConnectionRegistry,
|
||||
type ConnectionRegistry,
|
||||
} from "@suaveplan/api-ws/connection-registry";
|
||||
|
||||
class TenantCapGate {
|
||||
constructor(
|
||||
private readonly registry: ConnectionRegistry,
|
||||
private readonly maxPerTenant: number,
|
||||
) {}
|
||||
|
||||
accept(conn: { tenantId: TenantId }): boolean {
|
||||
return this.registry.countByTenant(conn.tenantId) < this.maxPerTenant;
|
||||
}
|
||||
}
|
||||
|
||||
const registry: ConnectionRegistry = new InMemoryConnectionRegistry();
|
||||
const gate = new TenantCapGate(registry, 1000);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface ConnectionRegistry { add, remove, lookupByTenant, lookupByTopic, countByTenant }`.
|
||||
- `class InMemoryConnectionRegistry implements ConnectionRegistry`.
|
||||
- `recordSubscription(connection, topic, tenant)` — wired by
|
||||
`SubscriptionBus.subscribe`.
|
||||
- `dropSubscription(connection, topic, tenant)` — wired by `unsubscribe`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The three indices (by id, by tenant, by `(topic, tenant)`) stay in sync
|
||||
through the `add` / `remove` / `recordSubscription` / `dropSubscription`
|
||||
quartet. `remove` walks the topic index because in practice a
|
||||
connection's full subscription set isn't carried on the `WsConnection`
|
||||
contract — engines may keep that state themselves.
|
||||
- `recordSubscription` silently ignores unknown connections so the
|
||||
subscription bus can call it without re-checking membership.
|
||||
- The `(topic, tenant)` key uses `::` as a delimiter; tenant ids that
|
||||
contain `::` are still distinguishable because the encoding is
|
||||
prefix-based, not lossy.
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { WsConnection } from "../connection/connection.js";
|
||||
import { InMemoryConnectionRegistry } from "./connection-registry.js";
|
||||
|
||||
const tenantA = "tenant-a" as unknown as TenantId;
|
||||
const tenantB = "tenant-b" as unknown as TenantId;
|
||||
|
||||
function makeConn(id: string, tenant: TenantId): WsConnection {
|
||||
return {
|
||||
id,
|
||||
principal: {
|
||||
id: "p1" as unknown as Principal["id"],
|
||||
scopes: [],
|
||||
claims: {},
|
||||
},
|
||||
tenantId: tenant,
|
||||
lastEventId: null,
|
||||
state: "open",
|
||||
subscribe(): void {
|
||||
/* no-op */
|
||||
},
|
||||
unsubscribe(): void {
|
||||
/* no-op */
|
||||
},
|
||||
send(): void {
|
||||
/* no-op */
|
||||
},
|
||||
close(): void {
|
||||
/* no-op */
|
||||
},
|
||||
ack(): void {
|
||||
/* no-op */
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("InMemoryConnectionRegistry", () => {
|
||||
it("lookupByTenant returns matching set", { timeout: 5000 }, () => {
|
||||
const reg = new InMemoryConnectionRegistry();
|
||||
reg.add(makeConn("c1", tenantA));
|
||||
reg.add(makeConn("c2", tenantA));
|
||||
reg.add(makeConn("c3", tenantA));
|
||||
reg.add(makeConn("c4", tenantB));
|
||||
reg.add(makeConn("c5", tenantB));
|
||||
expect(reg.lookupByTenant(tenantA).length).toBe(3);
|
||||
expect(reg.lookupByTenant(tenantB).length).toBe(2);
|
||||
});
|
||||
|
||||
it("countByTenant reflects buckets", { timeout: 5000 }, () => {
|
||||
const reg = new InMemoryConnectionRegistry();
|
||||
expect(reg.countByTenant(tenantA)).toBe(0);
|
||||
reg.add(makeConn("c1", tenantA));
|
||||
expect(reg.countByTenant(tenantA)).toBe(1);
|
||||
});
|
||||
|
||||
it("add is idempotent for the same id", { timeout: 5000 }, () => {
|
||||
const reg = new InMemoryConnectionRegistry();
|
||||
const conn = makeConn("c1", tenantA);
|
||||
reg.add(conn);
|
||||
reg.add(conn);
|
||||
expect(reg.countByTenant(tenantA)).toBe(1);
|
||||
});
|
||||
|
||||
it("remove is idempotent", { timeout: 5000 }, () => {
|
||||
const reg = new InMemoryConnectionRegistry();
|
||||
const conn = makeConn("c1", tenantA);
|
||||
reg.add(conn);
|
||||
reg.remove(conn);
|
||||
reg.remove(conn);
|
||||
expect(reg.countByTenant(tenantA)).toBe(0);
|
||||
});
|
||||
|
||||
it("remove clears the tenant bucket when empty", { timeout: 5000 }, () => {
|
||||
const reg = new InMemoryConnectionRegistry();
|
||||
const conn = makeConn("c1", tenantA);
|
||||
reg.add(conn);
|
||||
reg.remove(conn);
|
||||
expect(reg.lookupByTenant(tenantA)).toEqual([]);
|
||||
});
|
||||
|
||||
it("lookupByTopic returns connections subscribed to a (topic, tenant)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new InMemoryConnectionRegistry();
|
||||
const c1 = makeConn("c1", tenantA);
|
||||
const c2 = makeConn("c2", tenantA);
|
||||
reg.add(c1);
|
||||
reg.add(c2);
|
||||
reg.recordSubscription(c1, "orders", tenantA);
|
||||
reg.recordSubscription(c2, "orders", tenantA);
|
||||
expect(
|
||||
reg
|
||||
.lookupByTopic("orders", tenantA)
|
||||
.map((c) => c.id)
|
||||
.sort()
|
||||
).toEqual(["c1", "c2"]);
|
||||
});
|
||||
|
||||
it("lookupByTopic returns empty array for unknown topic", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new InMemoryConnectionRegistry();
|
||||
expect(reg.lookupByTopic("missing")).toEqual([]);
|
||||
expect(reg.lookupByTopic("missing", tenantA)).toEqual([]);
|
||||
});
|
||||
|
||||
it("dropSubscription removes the binding", { timeout: 5000 }, () => {
|
||||
const reg = new InMemoryConnectionRegistry();
|
||||
const c1 = makeConn("c1", tenantA);
|
||||
reg.add(c1);
|
||||
reg.recordSubscription(c1, "orders", tenantA);
|
||||
reg.dropSubscription(c1, "orders", tenantA);
|
||||
expect(reg.lookupByTopic("orders", tenantA)).toEqual([]);
|
||||
});
|
||||
|
||||
it("dropSubscription on unknown topic is a no-op", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new InMemoryConnectionRegistry();
|
||||
const c1 = makeConn("c1", tenantA);
|
||||
reg.dropSubscription(c1, "ghost", tenantA);
|
||||
expect(reg.lookupByTopic("ghost", tenantA)).toEqual([]);
|
||||
});
|
||||
|
||||
it("recordSubscription ignores unknown connection", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new InMemoryConnectionRegistry();
|
||||
const c1 = makeConn("c1", tenantA);
|
||||
reg.recordSubscription(c1, "orders", tenantA);
|
||||
expect(reg.lookupByTopic("orders", tenantA)).toEqual([]);
|
||||
});
|
||||
|
||||
it("removing a connection also drops its topic bindings", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new InMemoryConnectionRegistry();
|
||||
const c1 = makeConn("c1", tenantA);
|
||||
const c2 = makeConn("c2", tenantA);
|
||||
reg.add(c1);
|
||||
reg.add(c2);
|
||||
reg.recordSubscription(c1, "orders", tenantA);
|
||||
reg.recordSubscription(c2, "orders", tenantA);
|
||||
reg.remove(c1);
|
||||
expect(reg.lookupByTopic("orders", tenantA).map((c) => c.id)).toEqual([
|
||||
"c2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lookupByTopic without tenant uses the bare topic bucket", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new InMemoryConnectionRegistry();
|
||||
const c1 = makeConn("c1", tenantA);
|
||||
reg.add(c1);
|
||||
// Manually record under the untenanted key
|
||||
(reg as InMemoryConnectionRegistry).recordSubscription(
|
||||
c1,
|
||||
"global",
|
||||
undefined as unknown as TenantId
|
||||
);
|
||||
expect(reg.lookupByTopic("global").map((c) => c.id)).toEqual(["c1"]);
|
||||
});
|
||||
|
||||
it("removing the only connection in a topic bucket empties it", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new InMemoryConnectionRegistry();
|
||||
const solo = makeConn("solo", tenantA);
|
||||
reg.add(solo);
|
||||
reg.recordSubscription(solo, "orders", tenantA);
|
||||
reg.remove(solo);
|
||||
expect(reg.lookupByTopic("orders", tenantA)).toEqual([]);
|
||||
});
|
||||
|
||||
it("dropSubscription leaves other subscribers intact", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new InMemoryConnectionRegistry();
|
||||
const a = makeConn("a", tenantA);
|
||||
const b = makeConn("b", tenantA);
|
||||
reg.add(a);
|
||||
reg.add(b);
|
||||
reg.recordSubscription(a, "orders", tenantA);
|
||||
reg.recordSubscription(b, "orders", tenantA);
|
||||
reg.dropSubscription(a, "orders", tenantA);
|
||||
expect(reg.lookupByTopic("orders", tenantA).map((c) => c.id)).toEqual([
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import type { WsConnection } from "../connection/connection.js";
|
||||
|
||||
/**
|
||||
* Lookup contract for the host's per-plugin connection registry. The
|
||||
* concrete in-memory implementation lives below; multi-host deployments
|
||||
* implement the same interface against Redis / SurrealDB / whatever the
|
||||
* deployment chose.
|
||||
*/
|
||||
export interface ConnectionRegistry {
|
||||
add(connection: WsConnection): void;
|
||||
remove(connection: WsConnection): void;
|
||||
lookupByTenant(tenant: TenantId): readonly WsConnection[];
|
||||
lookupByTopic(topic: string, tenant?: TenantId): readonly WsConnection[];
|
||||
countByTenant(tenant: TenantId): number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal in-memory registry that backs the conformance harness and the
|
||||
* single-host default. Uses a `Set` keyed by connection id under the hood
|
||||
* so insertion / removal stay O(1) amortised.
|
||||
*
|
||||
* The `subscriptions` map is populated by {@link InMemoryConnectionRegistry.recordSubscription}
|
||||
* which the subscription bus calls on every successful `subscribe`.
|
||||
*/
|
||||
export class InMemoryConnectionRegistry implements ConnectionRegistry {
|
||||
private readonly byId = new Map<string, WsConnection>();
|
||||
private readonly byTenant = new Map<TenantId, Set<string>>();
|
||||
private readonly byTopic = new Map<string, Set<string>>();
|
||||
|
||||
public add(connection: WsConnection): void {
|
||||
if (this.byId.has(connection.id)) {
|
||||
return;
|
||||
}
|
||||
this.byId.set(connection.id, connection);
|
||||
const tenantBucket =
|
||||
this.byTenant.get(connection.tenantId) ?? new Set<string>();
|
||||
tenantBucket.add(connection.id);
|
||||
this.byTenant.set(connection.tenantId, tenantBucket);
|
||||
}
|
||||
|
||||
public remove(connection: WsConnection): void {
|
||||
if (!this.byId.has(connection.id)) {
|
||||
return;
|
||||
}
|
||||
this.byId.delete(connection.id);
|
||||
// `add` always populates byTenant in lockstep with byId, so a
|
||||
// membership check above suffices — the bucket is guaranteed.
|
||||
const tenantBucket = this.byTenant.get(
|
||||
connection.tenantId
|
||||
) as Set<string>;
|
||||
tenantBucket.delete(connection.id);
|
||||
if (tenantBucket.size === 0) {
|
||||
this.byTenant.delete(connection.tenantId);
|
||||
}
|
||||
for (const [topic, ids] of this.byTopic) {
|
||||
ids.delete(connection.id);
|
||||
if (ids.size === 0) {
|
||||
this.byTopic.delete(topic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public lookupByTenant(tenant: TenantId): readonly WsConnection[] {
|
||||
const ids = this.byTenant.get(tenant);
|
||||
if (ids === undefined) return [];
|
||||
const out: WsConnection[] = [];
|
||||
for (const id of ids) {
|
||||
// byId is updated in lockstep with byTenant, so the entry is
|
||||
// always present here.
|
||||
out.push(this.byId.get(id) as WsConnection);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public lookupByTopic(
|
||||
topic: string,
|
||||
tenant?: TenantId
|
||||
): readonly WsConnection[] {
|
||||
const ids = this.byTopic.get(this.topicKey(topic, tenant));
|
||||
if (ids === undefined) return [];
|
||||
const out: WsConnection[] = [];
|
||||
for (const id of ids) {
|
||||
out.push(this.byId.get(id) as WsConnection);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public countByTenant(tenant: TenantId): number {
|
||||
return this.byTenant.get(tenant)?.size ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by `SubscriptionBus` whenever a connection subscribes to a
|
||||
* topic. Indexed by the `(topic, tenant)` pair so lookups are O(1).
|
||||
*/
|
||||
public recordSubscription(
|
||||
connection: WsConnection,
|
||||
topic: string,
|
||||
tenant: TenantId
|
||||
): void {
|
||||
if (!this.byId.has(connection.id)) {
|
||||
return;
|
||||
}
|
||||
const key = this.topicKey(topic, tenant);
|
||||
const bucket = this.byTopic.get(key) ?? new Set<string>();
|
||||
bucket.add(connection.id);
|
||||
this.byTopic.set(key, bucket);
|
||||
}
|
||||
|
||||
/** Symmetric removal called by the subscription bus on `unsubscribe`. */
|
||||
public dropSubscription(
|
||||
connection: WsConnection,
|
||||
topic: string,
|
||||
tenant: TenantId
|
||||
): void {
|
||||
const key = this.topicKey(topic, tenant);
|
||||
const bucket = this.byTopic.get(key);
|
||||
if (bucket === undefined) return;
|
||||
bucket.delete(connection.id);
|
||||
if (bucket.size === 0) {
|
||||
this.byTopic.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private topicKey(topic: string, tenant?: TenantId): string {
|
||||
return tenant === undefined ? topic : `${tenant as string}::${topic}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ConnectionRegistry } from "./connection-registry.js";
|
||||
export { InMemoryConnectionRegistry } from "./connection-registry.js";
|
||||
Reference in New Issue
Block a user