Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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.

View File

@@ -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",
]);
});
});

View File

@@ -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}`;
}
}

View File

@@ -0,0 +1,2 @@
export type { ConnectionRegistry } from "./connection-registry.js";
export { InMemoryConnectionRegistry } from "./connection-registry.js";