Skip to content
9 changes: 9 additions & 0 deletions src/client-configuration.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { LoggingClient } from './logging';
import { RequestContext, RequestContextType } from './types';

export interface InstanceConfiguration {
instanceId: string;
vendorId: string;
schemaPrefix?: string;
fallbackConfiguration?: FallbackConfiguration;
}

export interface ClientConfiguration {
engineEndpoint: string;
engineToken: string;
instances?: InstanceConfiguration[];
defaultInstanceId?: string;
logging?: {
client?: LoggingClient;
logResults?: boolean;
Expand Down
6 changes: 5 additions & 1 deletion src/entitlements-client-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ClientConfiguration } from './client-configuration';
import { LoggingClient, SimpleLoggingClient } from './logging';
import { ConfigurationInputIsMissingException } from './exceptions/configuration-input-is-missing.exception';
import { SpiceDBEntitlementsClient } from './spicedb/spicedb-entitlements.client';
import { InstanceRegistry } from './instances/instance-registry';

export class EntitlementsClientFactory {
public static create(configuration: ClientConfiguration): SpiceDBEntitlementsClient {
Expand All @@ -13,13 +14,16 @@ export class EntitlementsClientFactory {
throw new ConfigurationInputIsMissingException('engineToken is required');
}

const registry = new InstanceRegistry(configuration, configuration.defaultInstanceId);

const { loggingClient, logResults } = this.configureLoggingClient(configuration.logging);

return new SpiceDBEntitlementsClient(
configuration,
loggingClient,
logResults,
configuration.fallbackConfiguration
configuration.fallbackConfiguration,
registry
);
}

Expand Down
5 changes: 5 additions & 0 deletions src/exceptions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './configuration-input-is-invalid.exception';
export * from './configuration-input-is-missing.exception';
export * from './instance-resolution.exception';
export * from './unknown-instance.exception';
export * from './instance-id-required.exception';
10 changes: 10 additions & 0 deletions src/exceptions/instance-id-required.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { InstanceResolutionException } from './instance-resolution.exception';

export class InstanceIdRequiredException extends InstanceResolutionException {
constructor(configuredInstanceIds: string[]) {
super(
`instanceId is required when more than one instance is configured and no defaultInstanceId is set. ` +
`Configured instances: ${configuredInstanceIds.join(', ')}`
);
}
}
6 changes: 6 additions & 0 deletions src/exceptions/instance-resolution.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export abstract class InstanceResolutionException extends Error {
protected constructor(message: string) {
super(message);
this.name = new.target.name;
}
}
14 changes: 14 additions & 0 deletions src/exceptions/unknown-instance.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { InstanceResolutionException } from './instance-resolution.exception';

export class UnknownInstanceException extends InstanceResolutionException {
constructor(
public readonly instanceId: string,
configuredInstanceIds: string[]
) {
super(
`Unknown instanceId '${instanceId}'. Configured instances: ${
configuredInstanceIds.length ? configuredInstanceIds.join(', ') : '<none>'
}`
);
}
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ export * from './types';
export * from './client-configuration';
export * from './entitlements-client-factory';
export * from './spicedb/spicedb-entitlements.client';
export * from './instances';
export * from './exceptions';
4 changes: 4 additions & 0 deletions src/instances/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './schema-scope';
export * from './schema-prefix';
export * from './instance-registry';
export * from './resolve-instance';
128 changes: 128 additions & 0 deletions src/instances/instance-registry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { InstanceRegistry, LEGACY_INSTANCE_ID } from './instance-registry';
import { ConfigurationInputIsInvalidException } from '../exceptions/configuration-input-is-invalid.exception';

const VENDOR_A = '2f9c1a44-7b0e-4a1e-9f8a-1c2d3e4f5a6b';
const VENDOR_B = '8b1d0e77-3c5a-4f2b-9d6e-7a8b9c0d1e2f';

describe(InstanceRegistry.name, () => {
it('should synthesise a legacy instance when no instances are configured', () => {
const registry = new InstanceRegistry({});

expect(registry.size).toBe(1);
expect(registry.instanceIds).toEqual([LEGACY_INSTANCE_ID]);
expect(registry.onlyInstance.scope.isLegacy).toBe(true);
});

it('should synthesise a legacy instance for an empty instances array', () => {
const registry = new InstanceRegistry({ instances: [] });

expect(registry.onlyInstance.scope.isLegacy).toBe(true);
});

it('should derive a schema prefix from each vendorId', () => {
const registry = new InstanceRegistry({
instances: [
{ instanceId: 'a', vendorId: VENDOR_A },
{ instanceId: 'b', vendorId: VENDOR_B }
]
});

expect(registry.get('a')?.scope.schemaPrefix).toBe('v_2f9c1a44_7b0e_4a1e_9f8a_1c2d3e4f5a6b');
expect(registry.get('b')?.scope.schemaPrefix).toBe('v_8b1d0e77_3c5a_4f2b_9d6e_7a8b9c0d1e2f');
});

it('should honour an explicit schemaPrefix override', () => {
const registry = new InstanceRegistry({
instances: [{ instanceId: 'a', vendorId: VENDOR_A, schemaPrefix: 'v_custom' }]
});

expect(registry.get('a')?.scope.schemaPrefix).toBe('v_custom');
});

it('should treat an explicit empty schemaPrefix as legacy', () => {
const registry = new InstanceRegistry({
instances: [{ instanceId: 'a', vendorId: VENDOR_A, schemaPrefix: '' }]
});

expect(registry.get('a')?.scope.isLegacy).toBe(true);
});

it('should throw on a missing instanceId', () => {
expect(() => new InstanceRegistry({ instances: [{ instanceId: '', vendorId: VENDOR_A }] })).toThrow(
ConfigurationInputIsInvalidException
);
});

it('should throw on a missing vendorId', () => {
expect(() => new InstanceRegistry({ instances: [{ instanceId: 'a', vendorId: '' }] })).toThrow(
ConfigurationInputIsInvalidException
);
});

it('should throw on a duplicate instanceId', () => {
expect(
() =>
new InstanceRegistry({
instances: [
{ instanceId: 'a', vendorId: VENDOR_A },
{ instanceId: 'a', vendorId: VENDOR_B }
]
})
).toThrow(ConfigurationInputIsInvalidException);
});

it('should throw on a duplicate vendorId', () => {
expect(
() =>
new InstanceRegistry({
instances: [
{ instanceId: 'a', vendorId: VENDOR_A },
{ instanceId: 'b', vendorId: VENDOR_A }
]
})
).toThrow(ConfigurationInputIsInvalidException);
});

it('should throw on an invalid schemaPrefix', () => {
expect(
() =>
new InstanceRegistry({
instances: [{ instanceId: 'a', vendorId: VENDOR_A, schemaPrefix: 'Not/Valid' }]
})
).toThrow(ConfigurationInputIsInvalidException);
});

it('should throw when defaultInstanceId is not a configured instance', () => {
expect(() => new InstanceRegistry({ instances: [{ instanceId: 'a', vendorId: VENDOR_A }] }, 'missing')).toThrow(
ConfigurationInputIsInvalidException
);
});

it('should take defaultInstanceId from the configuration object', () => {
const registry = new InstanceRegistry({
instances: [
{ instanceId: 'a', vendorId: VENDOR_A },
{ instanceId: 'b', vendorId: VENDOR_B }
],
defaultInstanceId: 'b'
});

expect(registry.defaultInstanceId).toBe('b');
});

it('should validate a defaultInstanceId given on the configuration object', () => {
expect(
() =>
new InstanceRegistry({
instances: [{ instanceId: 'a', vendorId: VENDOR_A }],
defaultInstanceId: 'missing'
})
).toThrow(ConfigurationInputIsInvalidException);
});

it('should accept a defaultInstanceId that is configured', () => {
const registry = new InstanceRegistry({ instances: [{ instanceId: 'a', vendorId: VENDOR_A }] }, 'a');

expect(registry.defaultInstanceId).toBe('a');
});
});
113 changes: 113 additions & 0 deletions src/instances/instance-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { ClientConfiguration, FallbackConfiguration, InstanceConfiguration } from '../client-configuration';
import { ConfigurationInputIsInvalidException } from '../exceptions/configuration-input-is-invalid.exception';
import { SchemaScope } from './schema-scope';
import { deriveSchemaPrefix, isValidSchemaPrefix } from './schema-prefix';

export const LEGACY_INSTANCE_ID = 'default';

export interface ResolvedInstance {
instanceId: string;
vendorId?: string;
scope: SchemaScope;
fallbackConfiguration?: FallbackConfiguration;
}

export class InstanceRegistry {
private readonly instances: Map<string, ResolvedInstance>;
private readonly order: string[];

public readonly defaultInstanceId?: string;

constructor(
configuration: Pick<ClientConfiguration, 'instances' | 'defaultInstanceId'>,
defaultInstanceId: string | undefined = configuration.defaultInstanceId
) {
this.defaultInstanceId = defaultInstanceId;
const declared = configuration.instances;
this.instances = new Map();
this.order = [];

if (!declared || declared.length === 0) {
const legacy: ResolvedInstance = {
instanceId: LEGACY_INSTANCE_ID,
scope: new SchemaScope('')
};
this.instances.set(legacy.instanceId, legacy);
this.order.push(legacy.instanceId);
} else {
const seenVendorIds = new Set<string>();
for (const instance of declared) {
this.assertInstance(instance, seenVendorIds);
const resolved: ResolvedInstance = {
instanceId: instance.instanceId,
vendorId: instance.vendorId,
scope: new SchemaScope(this.resolvePrefix(instance)),
fallbackConfiguration: instance.fallbackConfiguration
};
this.instances.set(resolved.instanceId, resolved);
this.order.push(resolved.instanceId);
}
}

if (defaultInstanceId !== undefined && !this.instances.has(defaultInstanceId)) {
throw new ConfigurationInputIsInvalidException(
`defaultInstanceId '${defaultInstanceId}' is not one of the configured instances: ${this.order.join(', ')}`
);
}
}

public get instanceIds(): string[] {
return [...this.order];
}

public get size(): number {
return this.order.length;
}

public get onlyInstance(): ResolvedInstance {
return this.instances.get(this.order[0]) as ResolvedInstance;
}

public get(instanceId: string): ResolvedInstance | undefined {
return this.instances.get(instanceId);
}

private resolvePrefix(instance: InstanceConfiguration): string {
if (instance.schemaPrefix !== undefined) {
return instance.schemaPrefix;
}

return deriveSchemaPrefix(instance.vendorId);
}

private assertInstance(instance: InstanceConfiguration, seenVendorIds: Set<string>): void {
if (!instance.instanceId) {
throw new ConfigurationInputIsInvalidException('instanceId is required for every configured instance');
}

if (this.instances.has(instance.instanceId)) {
throw new ConfigurationInputIsInvalidException(`Duplicate instanceId '${instance.instanceId}'`);
}

if (!instance.vendorId) {
throw new ConfigurationInputIsInvalidException(
`vendorId is required for instance '${instance.instanceId}'`
);
}

if (seenVendorIds.has(instance.vendorId)) {
throw new ConfigurationInputIsInvalidException(
`Duplicate vendorId '${instance.vendorId}' on instance '${instance.instanceId}'`
);
}
seenVendorIds.add(instance.vendorId);

const prefix = this.resolvePrefix(instance);
if (!isValidSchemaPrefix(prefix)) {
throw new ConfigurationInputIsInvalidException(
`Invalid schemaPrefix '${prefix}' for instance '${instance.instanceId}'. ` +
`Expected an empty string or a SpiceDB identifier matching /^[a-z_][a-z0-9_]{1,62}[a-z0-9]$/`
);
}
}
}
Loading
Loading