Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 251 additions & 0 deletions src/capabilities/CapabilityRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import type {
CapabilityDefinition,
CapabilitySnapshot,
RegistryOptions,
} from "./types.js";
import {
InvalidCapabilityIdentifierError,
InvalidCapabilityVersionError,
DuplicateCapabilityError,
} from "./errors.js";
import { isValidVersion, compareVersions } from "./versionUtils.js";

/**
* Capability identifier validation regex
* Format: lowercase alphanumeric words separated by dots (e.g., "membership.read", "access.check")
*/
const CAPABILITY_ID_REGEX = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$/;

/**
* Runtime capability registry for managing SDK capabilities
*
* Features:
* - Validates capability identifier syntax
* - Rejects duplicate registrations with conflicting versions
* - Provides deterministic capability checks
* - Maintains stable ordering for capability listing
* - Supports immutable snapshot creation
* - Protects against caller input mutation
*/
export class CapabilityRegistry {
private capabilities: Map<string, CapabilityDefinition> = new Map();
private orderedIds: string[] = [];
private options: Required<RegistryOptions>;

constructor(options: RegistryOptions = {}) {
this.options = {
duplicatePolicy: options.duplicatePolicy ?? 'error',
};
}

/**
* Validate a capability identifier
* @param id - Capability identifier to validate
* @throws InvalidCapabilityIdentifierError if identifier is invalid
*/
private validateIdentifier(id: string): void {
if (typeof id !== 'string' || id.length === 0) {
throw new InvalidCapabilityIdentifierError(id, 'Identifier must be a non-empty string');
}

if (!CAPABILITY_ID_REGEX.test(id)) {
throw new InvalidCapabilityIdentifierError(
id,
'Identifier must match format: lowercase alphanumeric words separated by dots (e.g., "membership.read")'
);
}
}

/**
* Validate a capability version
* @param version - Version string to validate
* @throws InvalidCapabilityVersionError if version is invalid
*/
private validateVersion(version: string): void {
if (typeof version !== 'string' || version.length === 0) {
throw new InvalidCapabilityVersionError(version, 'Version must be a non-empty string');
}

if (!isValidVersion(version)) {
throw new InvalidCapabilityVersionError(
version,
'Version must be a valid semantic version (e.g., "1.0.0", "2.1.3-alpha")'
);
}
}

/**
* Deep clone a capability definition to prevent caller mutation
* @param definition - Definition to clone
* @returns Cloned definition
*/
private cloneDefinition(definition: CapabilityDefinition): CapabilityDefinition {
return {
id: definition.id,
version: definition.version,
};
}

/**
* Register a capability definition
* @param definition - Capability definition to register
* @throws InvalidCapabilityIdentifierError if identifier is invalid
* @throws InvalidCapabilityVersionError if version is invalid
* @throws DuplicateCapabilityError if duplicate with conflicting version
*/
register(definition: CapabilityDefinition): void {
// Clone input to prevent caller mutation
const cloned = this.cloneDefinition(definition);

// Validate identifier
this.validateIdentifier(cloned.id);

// Validate version
this.validateVersion(cloned.version);

// Check for duplicates
const existing = this.capabilities.get(cloned.id);
if (existing) {
if (existing.version === cloned.version) {
// Identical duplicate
if (this.options.duplicatePolicy === 'error') {
throw new DuplicateCapabilityError(cloned.id, existing.version, cloned.version);
}
// 'ignore' policy: silently skip
// 'replace' policy: replace with new (same version, no effect)
return;
} else {
// Conflicting duplicate - always reject
throw new DuplicateCapabilityError(cloned.id, existing.version, cloned.version);
}
}

// Add new capability
this.capabilities.set(cloned.id, cloned);

// Maintain deterministic ordering: insert in sorted order
const insertIndex = this.orderedIds.findIndex((id) => id > cloned.id);
if (insertIndex === -1) {
this.orderedIds.push(cloned.id);
} else {
this.orderedIds.splice(insertIndex, 0, cloned.id);
}
}

/**
* Register multiple capability definitions
* @param definitions - Array of capability definitions to register
* @throws Error if any registration fails
*/
registerBatch(definitions: readonly CapabilityDefinition[]): void {
for (const definition of definitions) {
this.register(definition);
}
}

/**
* Check if a capability is registered
* @param id - Capability identifier to check
* @returns true if capability exists
*/
hasCapability(id: string): boolean {
return this.capabilities.has(id);
}

/**
* Get the version of a registered capability
* @param id - Capability identifier
* @returns Version string, or undefined if capability not found
*/
getCapabilityVersion(id: string): string | undefined {
const capability = this.capabilities.get(id);
return capability?.version;
}

/**
* Check if a capability meets a minimum version requirement
* @param id - Capability identifier
* @param minimumVersion - Minimum version required
* @returns true if capability exists and version >= minimumVersion
*/
satisfiesMinimumVersion(id: string, minimumVersion: string): boolean {
const capability = this.capabilities.get(id);
if (!capability) {
return false;
}

try {
return compareVersions(capability.version, minimumVersion) >= 0;
} catch {
// If version comparison fails, fall back to string comparison
return capability.version >= minimumVersion;
}
}

/**
* Get all registered capability identifiers in stable order
* @returns Array of capability identifiers
*/
listCapabilities(): readonly string[] {
return [...this.orderedIds];
}

/**
* Get all registered capability definitions in stable order
* @returns Array of capability definitions
*/
listDefinitions(): readonly CapabilityDefinition[] {
return this.orderedIds.map((id) => this.cloneDefinition(this.capabilities.get(id)!));
}

/**
* Get the total number of registered capabilities
* @returns Count of registered capabilities
*/
size(): number {
return this.capabilities.size;
}

/**
* Create an immutable snapshot of the current registry state
* @returns Immutable snapshot
*/
createSnapshot(): CapabilitySnapshot {
const capabilities = new Map<string, Readonly<CapabilityDefinition>>();

for (const [id, definition] of this.capabilities) {
capabilities.set(id, Object.freeze({ ...definition }));
}

return {
capabilities: Object.freeze(capabilities),
orderedIds: Object.freeze([...this.orderedIds]),
};
}

/**
* Clear all registered capabilities
*/
clear(): void {
this.capabilities.clear();
this.orderedIds = [];
}

/**
* Create a new registry instance from a snapshot
* @param snapshot - Snapshot to restore from
* @returns New CapabilityRegistry instance
*/
static fromSnapshot(snapshot: CapabilitySnapshot): CapabilityRegistry {
const registry = new CapabilityRegistry();

for (const id of snapshot.orderedIds) {
const definition = snapshot.capabilities.get(id);
if (definition) {
registry.register(definition);
}
}

return registry;
}
}
58 changes: 58 additions & 0 deletions src/capabilities/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Base error for capability-related errors
*/
export class CapabilityError extends Error {
constructor(message: string) {
super(message);
this.name = "CapabilityError";
Object.setPrototypeOf(this, CapabilityError.prototype);
}
}

/**
* Error thrown when a capability identifier has invalid syntax
*/
export class InvalidCapabilityIdentifierError extends CapabilityError {
public readonly identifier: string;

constructor(identifier: string, reason: string) {
super(`Invalid capability identifier "${identifier}": ${reason}`);
this.name = "InvalidCapabilityIdentifierError";
this.identifier = identifier;
Object.setPrototypeOf(this, InvalidCapabilityIdentifierError.prototype);
}
}

/**
* Error thrown when a capability version has invalid format
*/
export class InvalidCapabilityVersionError extends CapabilityError {
public readonly version: string;

constructor(version: string, reason: string) {
super(`Invalid capability version "${version}": ${reason}`);
this.name = "InvalidCapabilityVersionError";
this.version = version;
Object.setPrototypeOf(this, InvalidCapabilityVersionError.prototype);
}
}

/**
* Error thrown when attempting to register a duplicate capability with conflicting version
*/
export class DuplicateCapabilityError extends CapabilityError {
public readonly id: string;
public readonly existingVersion: string;
public readonly newVersion: string;

constructor(id: string, existingVersion: string, newVersion: string) {
super(
`Duplicate capability "${id}" with conflicting versions: existing="${existingVersion}", new="${newVersion}"`
);
this.name = "DuplicateCapabilityError";
this.id = id;
this.existingVersion = existingVersion;
this.newVersion = newVersion;
Object.setPrototypeOf(this, DuplicateCapabilityError.prototype);
}
}
4 changes: 4 additions & 0 deletions src/capabilities/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./types.js";
export * from "./errors.js";
export * from "./versionUtils.js";
export * from "./CapabilityRegistry.js";
29 changes: 29 additions & 0 deletions src/capabilities/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Capability definition interface
* Represents a capability with an identifier and version
*/
export interface CapabilityDefinition {
id: string;
version: string;
}

/**
* Immutable snapshot of the capability registry
*/
export interface CapabilitySnapshot {
readonly capabilities: ReadonlyMap<string, Readonly<CapabilityDefinition>>;
readonly orderedIds: ReadonlyArray<string>;
}

/**
* Registry configuration options
*/
export interface RegistryOptions {
/**
* Policy for handling duplicate capability definitions
* - 'error': Reject all duplicates (default)
* - 'ignore': Silently ignore duplicate identical definitions
* - 'replace': Replace existing definition with new one
*/
duplicatePolicy?: 'error' | 'ignore' | 'replace';
}
Loading
Loading