release: WRNexusJS 0.8.0
This commit is contained in:
@@ -110,6 +110,37 @@ instances. The default store is process-local memory.
|
||||
(default `console.log`), `requestIdKey` (default `"requestId"`), `now`.
|
||||
`RequestRecord` = `{ time, id, method, path, status, durationMs }`.
|
||||
|
||||
### Resilience — `@wrnexus/core`
|
||||
|
||||
`resilientCall` standardizes cancellation-aware timeouts, controlled retries,
|
||||
fixed or exponential backoff, fallback responses, circuit breaking, and bounded
|
||||
concurrency. Reuse a declarative circuit/bulkhead options object, or an explicit
|
||||
`CircuitBreaker`/`Bulkhead` instance, wherever calls must share health and
|
||||
capacity state.
|
||||
|
||||
```ts
|
||||
import { resilientCall } from "@wrnexus/core";
|
||||
|
||||
const paymentCircuit = { failures: 5, resetAfter: "30s" } as const;
|
||||
|
||||
const status = await resilientCall({
|
||||
timeout: "5s",
|
||||
retries: 3,
|
||||
retryDelay: "100ms",
|
||||
backoff: "exponential",
|
||||
circuitBreaker: paymentCircuit,
|
||||
bulkhead: { concurrency: 20, queue: 100 },
|
||||
run: (signal) => paymentProvider.checkStatus({ signal }),
|
||||
fallback: () => ({ state: "unavailable" }),
|
||||
});
|
||||
```
|
||||
|
||||
`CircuitBreaker.snapshot()` reports `closed`, `open`, or `half-open`, failure
|
||||
and success counts, and the remaining retry delay for health endpoints and
|
||||
development tooling. Fail-fast conditions use stable `WRN-RESILIENCE-*` codes.
|
||||
Core's existing `HealthRegistry`, `withIdempotency`, and pluggable stores/locks
|
||||
cover health reporting, idempotent requests, and distributed coordination.
|
||||
|
||||
### Caching — `@wrnexus/core`
|
||||
|
||||
| Export | Kind | Notes |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -4,6 +4,13 @@ export interface SchemaLike<T> {
|
||||
parse(input: unknown): T;
|
||||
}
|
||||
|
||||
export interface OutputSchemaLike<T> {
|
||||
readonly __output: T;
|
||||
parse(input: unknown): unknown;
|
||||
}
|
||||
export type InferEndpointSchema<TSchema> =
|
||||
TSchema extends OutputSchemaLike<infer TValue> ? TValue : never;
|
||||
|
||||
export interface EndpointErrorBody {
|
||||
code: string;
|
||||
message: string;
|
||||
@@ -23,8 +30,8 @@ export class EndpointError extends Error {
|
||||
}
|
||||
|
||||
export interface EndpointDefinition<I, O> {
|
||||
input?: SchemaLike<I>;
|
||||
output?: SchemaLike<O>;
|
||||
input?: SchemaLike<I> | OutputSchemaLike<I>;
|
||||
output?: SchemaLike<O> | OutputSchemaLike<O>;
|
||||
auth?: "optional" | "required";
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
@@ -43,18 +50,55 @@ function json(body: unknown, status = 200): Response {
|
||||
});
|
||||
}
|
||||
|
||||
function schemaValue<T>(schema: SchemaLike<T> | OutputSchemaLike<T>, input: unknown): T {
|
||||
const parsed = schema.parse(input);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"ok" in parsed &&
|
||||
"value" in parsed &&
|
||||
typeof (parsed as { ok?: unknown }).ok === "boolean"
|
||||
) {
|
||||
const result = parsed as { ok: boolean; value: T; errors?: unknown };
|
||||
if (!result.ok)
|
||||
throw new EndpointError(
|
||||
400,
|
||||
"VALIDATION_ERROR",
|
||||
"Endpoint validation failed.",
|
||||
result.errors,
|
||||
);
|
||||
return result.value;
|
||||
}
|
||||
return parsed as T;
|
||||
}
|
||||
|
||||
/** Define a validated, typed endpoint that can also drive SDK/OpenAPI generation. */
|
||||
export function defineEndpoint<
|
||||
InputSchema extends OutputSchemaLike<unknown>,
|
||||
OutputSchema extends OutputSchemaLike<unknown>,
|
||||
>(
|
||||
definition: Omit<
|
||||
EndpointDefinition<InferEndpointSchema<InputSchema>, InferEndpointSchema<OutputSchema>>,
|
||||
"input" | "output"
|
||||
> & {
|
||||
input: InputSchema;
|
||||
output: OutputSchema;
|
||||
},
|
||||
): DefinedEndpoint<InferEndpointSchema<InputSchema>, InferEndpointSchema<OutputSchema>>;
|
||||
export function defineEndpoint<I = unknown, O = unknown>(
|
||||
definition: EndpointDefinition<I, O>,
|
||||
): DefinedEndpoint<I, O> {
|
||||
): DefinedEndpoint<I, O>;
|
||||
export function defineEndpoint(
|
||||
definition: EndpointDefinition<unknown, unknown>,
|
||||
): DefinedEndpoint<unknown, unknown> {
|
||||
const endpoint = async (ctx: Context, rawInput?: unknown): Promise<Response> => {
|
||||
try {
|
||||
if (definition.auth === "required" && !ctx.user) {
|
||||
throw new EndpointError(401, "UNAUTHENTICATED", "Authentication is required.");
|
||||
}
|
||||
const input = definition.input ? definition.input.parse(rawInput) : (rawInput as I);
|
||||
const input = definition.input ? schemaValue(definition.input, rawInput) : rawInput;
|
||||
const rawOutput = await definition.handler(input, ctx);
|
||||
const output = definition.output ? definition.output.parse(rawOutput) : rawOutput;
|
||||
const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput;
|
||||
return output instanceof Response ? output : json({ data: output });
|
||||
} catch (error) {
|
||||
if (error instanceof EndpointError) {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Context } from "./context.ts";
|
||||
import type { Tenant } from "./tenant.ts";
|
||||
import type { Tracer } from "./observability.ts";
|
||||
|
||||
export type ExecutionKind =
|
||||
"http" | "api" | "action" | "loader" | "middleware" | "realtime" | "queue" | "cron" | "webhook";
|
||||
export interface ResponseContext {
|
||||
status: number;
|
||||
headers: Headers;
|
||||
setStatus(status: number): void;
|
||||
}
|
||||
export interface ExecutionContext {
|
||||
kind: ExecutionKind;
|
||||
id: string;
|
||||
request: Request;
|
||||
response: ResponseContext;
|
||||
user: unknown | null;
|
||||
session: unknown | null;
|
||||
tenant: Tenant | null;
|
||||
locale: string;
|
||||
timezone: string;
|
||||
db?: unknown;
|
||||
cache?: unknown;
|
||||
logger?: unknown;
|
||||
trace?: Tracer;
|
||||
signal: AbortSignal;
|
||||
deadline: Date | null;
|
||||
metadata: Record<string, unknown>;
|
||||
authorize(permission: string): void | Promise<void>;
|
||||
}
|
||||
export interface ExecutionContextInput extends Partial<
|
||||
Omit<
|
||||
ExecutionContext,
|
||||
"kind" | "id" | "request" | "response" | "signal" | "deadline" | "metadata" | "authorize"
|
||||
>
|
||||
> {
|
||||
kind: ExecutionKind;
|
||||
id?: string;
|
||||
request?: Request;
|
||||
response?: Partial<Pick<ResponseContext, "status">> & { headers?: HeadersInit };
|
||||
signal?: AbortSignal;
|
||||
deadline?: Date | number | null;
|
||||
timeoutMs?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
authorize?: (permission: string) => void | Promise<void>;
|
||||
}
|
||||
export function createExecutionContext(input: ExecutionContextInput): ExecutionContext {
|
||||
const controller = new AbortController();
|
||||
const source = input.signal;
|
||||
if (source?.aborted) controller.abort(source.reason);
|
||||
else source?.addEventListener("abort", () => controller.abort(source.reason), { once: true });
|
||||
const deadline =
|
||||
input.deadline instanceof Date
|
||||
? input.deadline
|
||||
: typeof input.deadline === "number"
|
||||
? new Date(input.deadline)
|
||||
: input.timeoutMs !== undefined
|
||||
? new Date(Date.now() + input.timeoutMs)
|
||||
: null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (deadline) {
|
||||
const delay = deadline.getTime() - Date.now();
|
||||
if (delay <= 0)
|
||||
controller.abort(new DOMException("Execution deadline exceeded", "TimeoutError"));
|
||||
else {
|
||||
timer = setTimeout(
|
||||
() => controller.abort(new DOMException("Execution deadline exceeded", "TimeoutError")),
|
||||
delay,
|
||||
);
|
||||
timer.unref?.();
|
||||
}
|
||||
}
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
const response: ResponseContext = {
|
||||
status: input.response?.status ?? 200,
|
||||
headers: new Headers(input.response?.headers),
|
||||
setStatus(status) {
|
||||
if (!Number.isInteger(status) || status < 100 || status > 599)
|
||||
throw new RangeError("response status must be an HTTP status code");
|
||||
this.status = status;
|
||||
},
|
||||
};
|
||||
return {
|
||||
kind: input.kind,
|
||||
id: input.id ?? crypto.randomUUID(),
|
||||
request: input.request ?? new Request(`https://execution.wrnexus.invalid/${input.kind}`),
|
||||
response,
|
||||
user: input.user ?? null,
|
||||
session: input.session ?? null,
|
||||
tenant: input.tenant ?? null,
|
||||
locale: input.locale ?? "en",
|
||||
timezone: input.timezone ?? "UTC",
|
||||
db: input.db,
|
||||
cache: input.cache,
|
||||
logger: input.logger,
|
||||
trace: input.trace,
|
||||
signal: controller.signal,
|
||||
deadline,
|
||||
metadata: { ...(input.metadata ?? {}) },
|
||||
authorize:
|
||||
input.authorize ??
|
||||
(() => {
|
||||
throw new Error("WRN-AUTHORIZATION-NOT-CONFIGURED");
|
||||
}),
|
||||
};
|
||||
}
|
||||
export function executionContextFromHttp(
|
||||
context: Context,
|
||||
kind: Extract<
|
||||
ExecutionKind,
|
||||
"http" | "api" | "action" | "loader" | "middleware" | "webhook"
|
||||
> = "http",
|
||||
input: Omit<
|
||||
ExecutionContextInput,
|
||||
"kind" | "request" | "user" | "tenant" | "locale" | "trace"
|
||||
> = {},
|
||||
): ExecutionContext {
|
||||
return createExecutionContext({
|
||||
...input,
|
||||
kind,
|
||||
request: context.req,
|
||||
user: context.user ?? null,
|
||||
session: context.session,
|
||||
tenant: context.tenant ?? null,
|
||||
locale: context.lang || "en",
|
||||
trace: context.tracer,
|
||||
signal: input.signal ?? context.req.signal,
|
||||
db: input.db ?? context.locals.db,
|
||||
cache: input.cache ?? context.locals.cache,
|
||||
logger: input.logger ?? context.locals.logger,
|
||||
metadata: { ...context.locals, ...(input.metadata ?? {}) },
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,13 @@ export type {
|
||||
TFunction,
|
||||
} from "./context.ts";
|
||||
export { createContext, withContextHeaders } from "./context.ts";
|
||||
export { createExecutionContext, executionContextFromHttp } from "./execution-context.ts";
|
||||
export type {
|
||||
ExecutionContext,
|
||||
ExecutionContextInput,
|
||||
ExecutionKind,
|
||||
ResponseContext,
|
||||
} from "./execution-context.ts";
|
||||
|
||||
export { escapeHtml, isSafeIslandName, isSafeRequestPath } from "./security.ts";
|
||||
export { csrfToken, verifyCsrf, csrfProtection, CSRF_COOKIE, CSRF_HEADER } from "./csrf.ts";
|
||||
@@ -132,6 +139,8 @@ export type {
|
||||
EndpointErrorBody,
|
||||
RpcClientOptions,
|
||||
SchemaLike,
|
||||
OutputSchemaLike,
|
||||
InferEndpointSchema,
|
||||
} from "./endpoint.ts";
|
||||
|
||||
export { defineAction, defineLoader, dedupe } from "./data.ts";
|
||||
@@ -143,8 +152,36 @@ export type {
|
||||
LoaderDefinition,
|
||||
} from "./data.ts";
|
||||
|
||||
export { requireTenant, tenantFromSubdomain, tenantMiddleware, tenantScope } from "./tenant.ts";
|
||||
export type { Tenant, TenantMiddlewareOptions, TenantResolver } from "./tenant.ts";
|
||||
export {
|
||||
assertTenantAccess,
|
||||
composeTenantResolvers,
|
||||
createTenantDirectory,
|
||||
createPersistentTenantDirectory,
|
||||
memoryTenantDirectoryStore,
|
||||
postgresTenantDirectoryStore,
|
||||
migrateTenants,
|
||||
POSTGRES_TENANT_DIRECTORY_SCHEMA,
|
||||
requireTenant,
|
||||
tenantFromDomain,
|
||||
tenantFromHeader,
|
||||
tenantFromPath,
|
||||
tenantFromSession,
|
||||
tenantFromSubdomain,
|
||||
tenantKey,
|
||||
tenantMiddleware,
|
||||
tenantScope,
|
||||
} from "./tenant.ts";
|
||||
export type {
|
||||
Tenant,
|
||||
TenantAuditEvent,
|
||||
TenantMembership,
|
||||
TenantQuota,
|
||||
TenantDirectoryStore,
|
||||
TenantSqlClient,
|
||||
TenantMiddlewareOptions,
|
||||
TenantResolver,
|
||||
TenantResource,
|
||||
} from "./tenant.ts";
|
||||
|
||||
export { createTracer, tracingMiddleware, withSpan } from "./observability.ts";
|
||||
export type { Span, SpanRecord, Tracer } from "./observability.ts";
|
||||
@@ -154,6 +191,21 @@ export type { FeatureFlags, FeatureRule, FeatureValue } from "./features.ts";
|
||||
|
||||
export { checkPerformanceBudgets, recommendedWebBudgets } from "./performance.ts";
|
||||
export type { BudgetViolation, PerformanceBudgets, PerformanceMeasurement } from "./performance.ts";
|
||||
export {
|
||||
Bulkhead,
|
||||
CircuitBreaker,
|
||||
ResilienceError,
|
||||
durationMs,
|
||||
resilientCall,
|
||||
} from "./resilience.ts";
|
||||
export type {
|
||||
BackoffStrategy,
|
||||
BulkheadOptions,
|
||||
CircuitBreakerOptions,
|
||||
CircuitBreakerSnapshot,
|
||||
Duration,
|
||||
ResilientCallOptions,
|
||||
} from "./resilience.ts";
|
||||
export {
|
||||
problem,
|
||||
serviceToken,
|
||||
|
||||
@@ -119,7 +119,7 @@ export interface RealtimeSecurityOptions {
|
||||
onViolation?(reason: string, client?: RoomClient): void;
|
||||
}
|
||||
|
||||
export interface RoomHandlers<TData = Record<string, unknown>> {
|
||||
export interface RoomHandlers<TData = Record<string, unknown>, TMessage = any> {
|
||||
/** Per-room abuse and payload controls. */
|
||||
security?: RealtimeSecurityOptions;
|
||||
/**
|
||||
@@ -130,20 +130,20 @@ export interface RoomHandlers<TData = Record<string, unknown>> {
|
||||
/** A client connected (a new tab joined the room). */
|
||||
onConnect?(client: RoomClient<TData>): void | Promise<void>;
|
||||
/** A message arrived (JSON is parsed; non-JSON arrives as a string). */
|
||||
onMessage?(client: RoomClient<TData>, message: any): void | Promise<void>;
|
||||
onMessage?(client: RoomClient<TData>, message: TMessage): void | Promise<void>;
|
||||
/** A client disconnected. */
|
||||
onLeave?(client: RoomClient<TData>): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface RoomDefinition<TData = Record<string, unknown>> {
|
||||
export interface RoomDefinition<TData = Record<string, unknown>, TMessage = any> {
|
||||
readonly __wrnexusRoom: true;
|
||||
readonly handlers: RoomHandlers<TData>;
|
||||
readonly handlers: RoomHandlers<TData, TMessage>;
|
||||
}
|
||||
|
||||
/** Define a realtime room. Export the result as the `default` of a realtime file. */
|
||||
export function defineRoom<TData = Record<string, unknown>>(
|
||||
handlers: RoomHandlers<TData>,
|
||||
): RoomDefinition<TData> {
|
||||
export function defineRoom<TData = Record<string, unknown>, TMessage = any>(
|
||||
handlers: RoomHandlers<TData, TMessage>,
|
||||
): RoomDefinition<TData, TMessage> {
|
||||
return { __wrnexusRoom: true, handlers };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
export type Duration = number | `${number}${"ms" | "s" | "m" | "h"}`;
|
||||
|
||||
export type BackoffStrategy = "fixed" | "exponential" | ((attempt: number) => Duration);
|
||||
|
||||
export interface CircuitBreakerOptions {
|
||||
failures: number;
|
||||
resetAfter: Duration;
|
||||
successesToClose?: number;
|
||||
}
|
||||
|
||||
export interface CircuitBreakerSnapshot {
|
||||
state: "closed" | "open" | "half-open";
|
||||
failures: number;
|
||||
successes: number;
|
||||
retryAfterMs: number;
|
||||
}
|
||||
|
||||
export class ResilienceError extends Error {
|
||||
constructor(
|
||||
public readonly code:
|
||||
| "WRN-RESILIENCE-TIMEOUT"
|
||||
| "WRN-RESILIENCE-ABORTED"
|
||||
| "WRN-RESILIENCE-CIRCUIT-OPEN"
|
||||
| "WRN-RESILIENCE-BULKHEAD-FULL",
|
||||
message: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "ResilienceError";
|
||||
}
|
||||
}
|
||||
|
||||
export function durationMs(value: Duration): number {
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value) || value < 0)
|
||||
throw new TypeError("Duration must be finite and non-negative.");
|
||||
return value;
|
||||
}
|
||||
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(value);
|
||||
if (!match) throw new TypeError(`Invalid duration: ${value}`);
|
||||
const scale = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 }[match[2]!]!;
|
||||
return Number(match[1]) * scale;
|
||||
}
|
||||
|
||||
export class CircuitBreaker {
|
||||
private failures = 0;
|
||||
private successes = 0;
|
||||
private openedAt = 0;
|
||||
private probing = false;
|
||||
|
||||
constructor(private readonly options: CircuitBreakerOptions) {
|
||||
if (!Number.isInteger(options.failures) || options.failures < 1) {
|
||||
throw new TypeError("Circuit breaker failures must be a positive integer.");
|
||||
}
|
||||
durationMs(options.resetAfter);
|
||||
}
|
||||
|
||||
snapshot(now = Date.now()): CircuitBreakerSnapshot {
|
||||
const resetAfter = durationMs(this.options.resetAfter);
|
||||
const elapsed = now - this.openedAt;
|
||||
const open = this.openedAt > 0 && elapsed < resetAfter;
|
||||
return {
|
||||
state: open ? "open" : this.openedAt > 0 ? "half-open" : "closed",
|
||||
failures: this.failures,
|
||||
successes: this.successes,
|
||||
retryAfterMs: open ? Math.max(0, resetAfter - elapsed) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
async execute<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const health = this.snapshot();
|
||||
if (health.state === "open" || (health.state === "half-open" && this.probing)) {
|
||||
throw new ResilienceError(
|
||||
"WRN-RESILIENCE-CIRCUIT-OPEN",
|
||||
`Circuit is open; retry after ${health.retryAfterMs}ms.`,
|
||||
);
|
||||
}
|
||||
if (health.state === "half-open") this.probing = true;
|
||||
try {
|
||||
const value = await operation();
|
||||
this.failures = 0;
|
||||
this.successes += 1;
|
||||
if (this.successes >= (this.options.successesToClose ?? 1)) this.openedAt = 0;
|
||||
return value;
|
||||
} catch (error) {
|
||||
this.successes = 0;
|
||||
this.failures += 1;
|
||||
if (this.failures >= this.options.failures) this.openedAt = Date.now();
|
||||
throw error;
|
||||
} finally {
|
||||
this.probing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface BulkheadOptions {
|
||||
concurrency: number;
|
||||
queue?: number;
|
||||
}
|
||||
|
||||
export class Bulkhead {
|
||||
private active = 0;
|
||||
private readonly waiting: Array<() => void> = [];
|
||||
|
||||
constructor(private readonly options: BulkheadOptions) {
|
||||
if (!Number.isInteger(options.concurrency) || options.concurrency < 1) {
|
||||
throw new TypeError("Bulkhead concurrency must be a positive integer.");
|
||||
}
|
||||
if (options.queue !== undefined && (!Number.isInteger(options.queue) || options.queue < 0)) {
|
||||
throw new TypeError("Bulkhead queue must be a non-negative integer.");
|
||||
}
|
||||
}
|
||||
|
||||
get snapshot(): Readonly<{ active: number; queued: number; capacity: number }> {
|
||||
return { active: this.active, queued: this.waiting.length, capacity: this.options.concurrency };
|
||||
}
|
||||
|
||||
async execute<T>(operation: () => Promise<T>): Promise<T> {
|
||||
if (this.active >= this.options.concurrency) {
|
||||
if (this.waiting.length >= (this.options.queue ?? 0)) {
|
||||
throw new ResilienceError(
|
||||
"WRN-RESILIENCE-BULKHEAD-FULL",
|
||||
"Bulkhead capacity is exhausted.",
|
||||
);
|
||||
}
|
||||
await new Promise<void>((resolve) => this.waiting.push(resolve));
|
||||
}
|
||||
this.active += 1;
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
this.active -= 1;
|
||||
this.waiting.shift()?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResilientCallOptions<T> {
|
||||
run: (signal: AbortSignal, attempt: number) => Promise<T>;
|
||||
timeout?: Duration;
|
||||
retries?: number;
|
||||
retryDelay?: Duration;
|
||||
backoff?: BackoffStrategy;
|
||||
circuitBreaker?: CircuitBreaker | CircuitBreakerOptions;
|
||||
bulkhead?: Bulkhead | BulkheadOptions;
|
||||
signal?: AbortSignal;
|
||||
retryWhen?: (error: unknown, attempt: number) => boolean | Promise<boolean>;
|
||||
fallback?: (error: unknown, signal: AbortSignal) => T | Promise<T>;
|
||||
onRetry?: (error: unknown, attempt: number, delayMs: number) => void;
|
||||
}
|
||||
|
||||
const breakerInstances = new WeakMap<CircuitBreakerOptions, CircuitBreaker>();
|
||||
const bulkheadInstances = new WeakMap<BulkheadOptions, Bulkhead>();
|
||||
|
||||
function breakerFor(value: CircuitBreaker | CircuitBreakerOptions): CircuitBreaker {
|
||||
if (value instanceof CircuitBreaker) return value;
|
||||
const existing = breakerInstances.get(value);
|
||||
if (existing) return existing;
|
||||
const created = new CircuitBreaker(value);
|
||||
breakerInstances.set(value, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function bulkheadFor(value: Bulkhead | BulkheadOptions): Bulkhead {
|
||||
if (value instanceof Bulkhead) return value;
|
||||
const existing = bulkheadInstances.get(value);
|
||||
if (existing) return existing;
|
||||
const created = new Bulkhead(value);
|
||||
bulkheadInstances.set(value, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function abortable<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) throw signal.reason;
|
||||
let cleanup = () => {};
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
const listener = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", listener, { once: true });
|
||||
cleanup = () => signal.removeEventListener("abort", listener);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([operation, aborted]);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function abortError(signal: AbortSignal): ResilienceError {
|
||||
return new ResilienceError("WRN-RESILIENCE-ABORTED", "Resilient call was aborted.", {
|
||||
cause: signal.reason,
|
||||
});
|
||||
}
|
||||
|
||||
async function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(abortError(signal));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function resilientCall<T>(options: ResilientCallOptions<T>): Promise<T> {
|
||||
const retries = options.retries ?? 0;
|
||||
if (!Number.isInteger(retries) || retries < 0)
|
||||
throw new TypeError("Retries must be a non-negative integer.");
|
||||
const breaker = options.circuitBreaker ? breakerFor(options.circuitBreaker) : undefined;
|
||||
const bulkhead = options.bulkhead ? bulkheadFor(options.bulkhead) : undefined;
|
||||
const invoke = async (): Promise<T> => {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= retries + 1; attempt += 1) {
|
||||
if (options.signal?.aborted) throw abortError(options.signal);
|
||||
const controller = new AbortController();
|
||||
const forwardAbort = () => controller.abort(options.signal?.reason);
|
||||
options.signal?.addEventListener("abort", forwardAbort, { once: true });
|
||||
const timeout = options.timeout === undefined ? undefined : durationMs(options.timeout);
|
||||
const timer =
|
||||
timeout === undefined ? undefined : setTimeout(() => controller.abort("timeout"), timeout);
|
||||
try {
|
||||
const run = () => abortable(options.run(controller.signal, attempt), controller.signal);
|
||||
return await (breaker ? breaker.execute(run) : run());
|
||||
} catch (caught) {
|
||||
lastError =
|
||||
controller.signal.aborted && !options.signal?.aborted
|
||||
? new ResilienceError(
|
||||
"WRN-RESILIENCE-TIMEOUT",
|
||||
`Operation timed out after ${timeout}ms.`,
|
||||
{ cause: caught },
|
||||
)
|
||||
: caught;
|
||||
if (attempt > retries || !(await (options.retryWhen?.(lastError, attempt) ?? true))) break;
|
||||
const base = durationMs(options.retryDelay ?? 100);
|
||||
const wait =
|
||||
typeof options.backoff === "function"
|
||||
? durationMs(options.backoff(attempt))
|
||||
: options.backoff === "exponential"
|
||||
? base * 2 ** (attempt - 1)
|
||||
: base;
|
||||
options.onRetry?.(lastError, attempt, wait);
|
||||
await delay(wait, options.signal);
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
options.signal?.removeEventListener("abort", forwardAbort);
|
||||
}
|
||||
}
|
||||
if (options.fallback)
|
||||
return options.fallback(lastError, options.signal ?? new AbortController().signal);
|
||||
throw lastError;
|
||||
};
|
||||
return bulkhead ? bulkhead.execute(invoke) : invoke();
|
||||
}
|
||||
@@ -7,6 +7,38 @@ export interface Tenant {
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TenantResource {
|
||||
tenantId: string;
|
||||
}
|
||||
export interface TenantMembership {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
roles?: string[];
|
||||
workspaceIds?: string[];
|
||||
}
|
||||
export interface TenantAuditEvent {
|
||||
tenantId: string;
|
||||
action: string;
|
||||
actorId?: string;
|
||||
resource?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface TenantQuota {
|
||||
tenantId: string;
|
||||
resource: string;
|
||||
limit: number;
|
||||
usage: number;
|
||||
}
|
||||
export interface TenantDirectoryStore {
|
||||
putMembership(value: TenantMembership): Promise<void>;
|
||||
getMembership(tenantId: string, userId: string): Promise<TenantMembership | null>;
|
||||
listMemberships(tenantId: string): Promise<TenantMembership[]>;
|
||||
putQuota(value: TenantQuota): Promise<void>;
|
||||
getQuota(tenantId: string, resource: string): Promise<TenantQuota | null>;
|
||||
}
|
||||
|
||||
export type TenantResolver = (ctx: Context) => Tenant | null | Promise<Tenant | null>;
|
||||
|
||||
export interface TenantMiddlewareOptions {
|
||||
@@ -41,6 +73,55 @@ export function tenantFromSubdomain(
|
||||
};
|
||||
}
|
||||
|
||||
export function tenantFromDomain(
|
||||
lookup: (domain: string, ctx: Context) => Tenant | null | Promise<Tenant | null>,
|
||||
): TenantResolver {
|
||||
return (ctx) => lookup(ctx.url.hostname.toLowerCase(), ctx);
|
||||
}
|
||||
|
||||
export function tenantFromPath(
|
||||
lookup: (slug: string, ctx: Context) => Tenant | null | Promise<Tenant | null>,
|
||||
prefix = "",
|
||||
): TenantResolver {
|
||||
return (ctx) => {
|
||||
const segments = ctx.url.pathname.split("/").filter(Boolean);
|
||||
const normalized = prefix.replace(/^\/+|\/+$/g, "");
|
||||
const slug = normalized ? (segments[0] === normalized ? segments[1] : undefined) : segments[0];
|
||||
return slug ? lookup(slug, ctx) : null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Header resolution is intentionally opt-in and must only be used behind a trusted proxy. */
|
||||
export function tenantFromHeader(
|
||||
lookup: (id: string, ctx: Context) => Tenant | null | Promise<Tenant | null>,
|
||||
header = "x-wrnexus-tenant",
|
||||
): TenantResolver {
|
||||
return (ctx) => {
|
||||
const value = ctx.req.headers.get(header)?.trim();
|
||||
return value ? lookup(value, ctx) : null;
|
||||
};
|
||||
}
|
||||
|
||||
export function tenantFromSession(
|
||||
resolveId: (ctx: Context) => string | null | Promise<string | null>,
|
||||
lookup: (id: string, ctx: Context) => Tenant | null | Promise<Tenant | null>,
|
||||
): TenantResolver {
|
||||
return async (ctx) => {
|
||||
const id = await resolveId(ctx);
|
||||
return id ? lookup(id, ctx) : null;
|
||||
};
|
||||
}
|
||||
|
||||
export function composeTenantResolvers(...resolvers: TenantResolver[]): TenantResolver {
|
||||
return async (ctx) => {
|
||||
for (const resolver of resolvers) {
|
||||
const tenant = await resolver(ctx);
|
||||
if (tenant) return tenant;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
export function requireTenant(ctx: Context): Tenant {
|
||||
if (!ctx.tenant)
|
||||
throw new Error("WRN-TENANT-REQUIRED: tenant middleware has not resolved a tenant.");
|
||||
@@ -54,3 +135,230 @@ export function tenantScope<T extends object>(
|
||||
): T & { tenantId: string } {
|
||||
return Object.assign(Object.create(repository), { tenantId: tenant.id });
|
||||
}
|
||||
|
||||
export function assertTenantAccess(tenant: Tenant, resource: TenantResource): void {
|
||||
if (!resource.tenantId || resource.tenantId !== tenant.id)
|
||||
throw new Error("WRN-TENANT-CROSS-ACCESS: resource does not belong to the active tenant.");
|
||||
}
|
||||
|
||||
export function tenantKey(tenant: Tenant | string, ...parts: Array<string | number>): string {
|
||||
const id = typeof tenant === "string" ? tenant : tenant.id;
|
||||
if (!id.trim() || id.includes(":"))
|
||||
throw new TypeError("WRN-TENANT-KEY: tenant id must be non-empty and cannot contain ':'.");
|
||||
return [
|
||||
"tenant",
|
||||
encodeURIComponent(id),
|
||||
...parts.map((part) => encodeURIComponent(String(part))),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function createTenantDirectory(
|
||||
options: { audit?: (event: TenantAuditEvent) => void | Promise<void>; now?: () => number } = {},
|
||||
) {
|
||||
const memberships = new Map<string, TenantMembership>();
|
||||
const quotas = new Map<string, Map<string, number>>();
|
||||
const now = options.now ?? Date.now;
|
||||
const key = (tenantId: string, userId: string) => `${tenantId}\0${userId}`;
|
||||
return {
|
||||
async addMembership(membership: TenantMembership, actorId?: string) {
|
||||
if (!membership.tenantId || !membership.userId)
|
||||
throw new TypeError("tenantId and userId are required");
|
||||
memberships.set(key(membership.tenantId, membership.userId), structuredClone(membership));
|
||||
await options.audit?.({
|
||||
tenantId: membership.tenantId,
|
||||
action: "membership.added",
|
||||
actorId,
|
||||
resource: membership.userId,
|
||||
createdAt: now(),
|
||||
});
|
||||
},
|
||||
membership(tenantId: string, userId: string) {
|
||||
const value = memberships.get(key(tenantId, userId));
|
||||
return value ? structuredClone(value) : null;
|
||||
},
|
||||
async switchWorkspace(tenantId: string, userId: string, workspaceId: string) {
|
||||
const membership = memberships.get(key(tenantId, userId));
|
||||
if (!membership?.workspaceIds?.includes(workspaceId))
|
||||
throw new Error("WRN-TENANT-WORKSPACE-DENIED");
|
||||
await options.audit?.({
|
||||
tenantId,
|
||||
action: "workspace.switched",
|
||||
actorId: userId,
|
||||
resource: workspaceId,
|
||||
createdAt: now(),
|
||||
});
|
||||
return { tenantId, workspaceId };
|
||||
},
|
||||
setQuota(tenantId: string, resource: string, limit: number) {
|
||||
if (!Number.isFinite(limit) || limit < 0)
|
||||
throw new RangeError("tenant quota must be non-negative");
|
||||
const values = quotas.get(tenantId) ?? new Map();
|
||||
values.set(resource, limit);
|
||||
quotas.set(tenantId, values);
|
||||
},
|
||||
enforceQuota(tenantId: string, resource: string, usage: number, requested = 0) {
|
||||
const limit = quotas.get(tenantId)?.get(resource);
|
||||
if (limit !== undefined && usage + requested > limit)
|
||||
throw new Error(`WRN-TENANT-QUOTA: ${resource} quota exceeded.`);
|
||||
return { usage, requested, limit };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function memoryTenantDirectoryStore(): TenantDirectoryStore {
|
||||
const memberships = new Map<string, TenantMembership>();
|
||||
const quotas = new Map<string, TenantQuota>();
|
||||
return {
|
||||
async putMembership(value) {
|
||||
memberships.set(`${value.tenantId}\0${value.userId}`, structuredClone(value));
|
||||
},
|
||||
async getMembership(tenantId, userId) {
|
||||
const value = memberships.get(`${tenantId}\0${userId}`);
|
||||
return value ? structuredClone(value) : null;
|
||||
},
|
||||
async listMemberships(tenantId) {
|
||||
return [...memberships.values()]
|
||||
.filter((value) => value.tenantId === tenantId)
|
||||
.map((value) => structuredClone(value));
|
||||
},
|
||||
async putQuota(value) {
|
||||
quotas.set(`${value.tenantId}\0${value.resource}`, structuredClone(value));
|
||||
},
|
||||
async getQuota(tenantId, resource) {
|
||||
const value = quotas.get(`${tenantId}\0${resource}`);
|
||||
return value ? structuredClone(value) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPersistentTenantDirectory(
|
||||
store: TenantDirectoryStore,
|
||||
options: { audit?: (event: TenantAuditEvent) => void | Promise<void>; now?: () => number } = {},
|
||||
) {
|
||||
const now = options.now ?? Date.now;
|
||||
return {
|
||||
async addMembership(membership: TenantMembership, actorId?: string) {
|
||||
if (!membership.tenantId || !membership.userId)
|
||||
throw new TypeError("tenantId and userId are required");
|
||||
await store.putMembership(structuredClone(membership));
|
||||
await options.audit?.({
|
||||
tenantId: membership.tenantId,
|
||||
action: "membership.added",
|
||||
actorId,
|
||||
resource: membership.userId,
|
||||
createdAt: now(),
|
||||
});
|
||||
},
|
||||
membership: (tenantId: string, userId: string) => store.getMembership(tenantId, userId),
|
||||
memberships: (tenantId: string) => store.listMemberships(tenantId),
|
||||
async switchWorkspace(tenantId: string, userId: string, workspaceId: string) {
|
||||
const membership = await store.getMembership(tenantId, userId);
|
||||
if (!membership?.workspaceIds?.includes(workspaceId))
|
||||
throw new Error("WRN-TENANT-WORKSPACE-DENIED");
|
||||
await options.audit?.({
|
||||
tenantId,
|
||||
action: "workspace.switched",
|
||||
actorId: userId,
|
||||
resource: workspaceId,
|
||||
createdAt: now(),
|
||||
});
|
||||
return { tenantId, workspaceId };
|
||||
},
|
||||
async setQuota(tenantId: string, resource: string, limit: number, usage = 0) {
|
||||
if (!Number.isFinite(limit) || limit < 0 || !Number.isFinite(usage) || usage < 0)
|
||||
throw new RangeError("tenant quota values must be non-negative");
|
||||
await store.putQuota({ tenantId, resource, limit, usage });
|
||||
},
|
||||
async consumeQuota(tenantId: string, resource: string, requested: number) {
|
||||
if (!Number.isFinite(requested) || requested < 0)
|
||||
throw new RangeError("requested quota must be non-negative");
|
||||
const quota = await store.getQuota(tenantId, resource);
|
||||
if (quota && quota.usage + requested > quota.limit)
|
||||
throw new Error(`WRN-TENANT-QUOTA: ${resource} quota exceeded.`);
|
||||
if (quota) {
|
||||
quota.usage += requested;
|
||||
await store.putQuota(quota);
|
||||
}
|
||||
return quota;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface TenantSqlClient {
|
||||
query<T = Record<string, unknown>>(sql: string, parameters?: unknown[]): Promise<{ rows: T[] }>;
|
||||
}
|
||||
export function postgresTenantDirectoryStore(db: TenantSqlClient): TenantDirectoryStore {
|
||||
return {
|
||||
async putMembership(value) {
|
||||
await db.query(
|
||||
`INSERT INTO wrnexus_tenant_memberships (tenant_id,user_id,roles,workspace_ids) VALUES ($1,$2,$3,$4) ON CONFLICT (tenant_id,user_id) DO UPDATE SET roles=$3,workspace_ids=$4`,
|
||||
[
|
||||
value.tenantId,
|
||||
value.userId,
|
||||
JSON.stringify(value.roles ?? []),
|
||||
JSON.stringify(value.workspaceIds ?? []),
|
||||
],
|
||||
);
|
||||
},
|
||||
async getMembership(tenantId, userId) {
|
||||
const result = await db.query<any>(
|
||||
`SELECT tenant_id AS "tenantId",user_id AS "userId",roles,workspace_ids AS "workspaceIds" FROM wrnexus_tenant_memberships WHERE tenant_id=$1 AND user_id=$2`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
return result.rows[0] ?? null;
|
||||
},
|
||||
async listMemberships(tenantId) {
|
||||
const result = await db.query<TenantMembership>(
|
||||
`SELECT tenant_id AS "tenantId",user_id AS "userId",roles,workspace_ids AS "workspaceIds" FROM wrnexus_tenant_memberships WHERE tenant_id=$1 ORDER BY user_id`,
|
||||
[tenantId],
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
async putQuota(value) {
|
||||
await db.query(
|
||||
`INSERT INTO wrnexus_tenant_quotas (tenant_id,resource,quota_limit,usage) VALUES ($1,$2,$3,$4) ON CONFLICT (tenant_id,resource) DO UPDATE SET quota_limit=$3,usage=$4`,
|
||||
[value.tenantId, value.resource, value.limit, value.usage],
|
||||
);
|
||||
},
|
||||
async getQuota(tenantId, resource) {
|
||||
const result = await db.query<any>(
|
||||
`SELECT tenant_id AS "tenantId",resource,quota_limit AS "limit",usage FROM wrnexus_tenant_quotas WHERE tenant_id=$1 AND resource=$2`,
|
||||
[tenantId, resource],
|
||||
);
|
||||
return result.rows[0] ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const POSTGRES_TENANT_DIRECTORY_SCHEMA = `CREATE TABLE IF NOT EXISTS wrnexus_tenant_memberships (tenant_id text NOT NULL,user_id text NOT NULL,roles jsonb NOT NULL DEFAULT '[]',workspace_ids jsonb NOT NULL DEFAULT '[]',PRIMARY KEY (tenant_id,user_id)); CREATE TABLE IF NOT EXISTS wrnexus_tenant_quotas (tenant_id text NOT NULL,resource text NOT NULL,quota_limit bigint NOT NULL,usage bigint NOT NULL DEFAULT 0,PRIMARY KEY (tenant_id,resource));`;
|
||||
|
||||
export async function migrateTenants<T extends Tenant>(
|
||||
tenants: T[],
|
||||
migrate: (tenant: T) => void | Promise<void>,
|
||||
options: { concurrency?: number; continueOnError?: boolean } = {},
|
||||
) {
|
||||
const concurrency = options.concurrency ?? 4;
|
||||
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 32)
|
||||
throw new RangeError("Tenant migration concurrency must be between 1 and 32");
|
||||
const pending = [...tenants];
|
||||
const migrated: string[] = [];
|
||||
const failed: Array<{ tenantId: string; error: string }> = [];
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(concurrency, pending.length) }, async () => {
|
||||
while (pending.length) {
|
||||
const tenant = pending.shift()!;
|
||||
try {
|
||||
await migrate(tenant);
|
||||
migrated.push(tenant.id);
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
tenantId: tenant.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
if (!options.continueOnError) pending.length = 0;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { migrated, failed };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createContext, defineEndpoint } from "../src/index.ts";
|
||||
import { v } from "@wrnexus/validation";
|
||||
|
||||
const user = v.object({ name: v.string().min(2), email: v.string().email() });
|
||||
const endpoint = defineEndpoint({
|
||||
input: user,
|
||||
output: user,
|
||||
handler(input) {
|
||||
return input;
|
||||
},
|
||||
});
|
||||
|
||||
test("typed endpoints unwrap official validation schemas and return bounded validation errors", async () => {
|
||||
const request = new Request("https://example.test/api/user");
|
||||
const ctx = createContext(request, new URL(request.url));
|
||||
const invalid = await endpoint(ctx, { name: "A", email: "bad" });
|
||||
expect(invalid.status).toBe(400);
|
||||
expect(await invalid.json()).toEqual({
|
||||
error: {
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Endpoint validation failed.",
|
||||
details: {
|
||||
name: "Must be at least 2 characters",
|
||||
email: "Must be a valid email",
|
||||
},
|
||||
},
|
||||
});
|
||||
const valid = await endpoint(ctx, { name: "Ada", email: "ada@example.test" });
|
||||
expect(await valid.json()).toEqual({ data: { name: "Ada", email: "ada@example.test" } });
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createContext, createExecutionContext, executionContextFromHttp } from "../src/index.ts";
|
||||
|
||||
test("unified execution context spans HTTP and background operations", async () => {
|
||||
const http = createContext(
|
||||
new Request("https://app.test/users"),
|
||||
new URL("https://app.test/users"),
|
||||
);
|
||||
http.lang = "fr";
|
||||
http.user = { id: "u1" };
|
||||
http.locals.db = { users: true };
|
||||
const execution = executionContextFromHttp(http, "action", {
|
||||
authorize: (permission) => {
|
||||
expect(permission).toBe("users.create");
|
||||
},
|
||||
});
|
||||
await execution.authorize("users.create");
|
||||
expect(execution).toMatchObject({
|
||||
kind: "action",
|
||||
locale: "fr",
|
||||
user: { id: "u1" },
|
||||
db: { users: true },
|
||||
});
|
||||
execution.response.setStatus(201);
|
||||
expect(execution.response.status).toBe(201);
|
||||
const queue = createExecutionContext({ kind: "queue", metadata: { job: "email" } });
|
||||
expect(queue.request.url).toBe("https://execution.wrnexus.invalid/queue");
|
||||
});
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
defineFeatureFlags,
|
||||
defineLoader,
|
||||
tenantFromSubdomain,
|
||||
assertTenantAccess,
|
||||
createTenantDirectory,
|
||||
tenantKey,
|
||||
tracingMiddleware,
|
||||
} from "../src/index.ts";
|
||||
|
||||
@@ -37,6 +40,31 @@ test("typed endpoints validate authentication and preserve a stable JSON envelop
|
||||
});
|
||||
});
|
||||
|
||||
test("tenant boundaries, memberships, workspaces, quotas, and audit events fail closed", async () => {
|
||||
const events: string[] = [];
|
||||
const directory = createTenantDirectory({
|
||||
audit: (event) => {
|
||||
events.push(event.action);
|
||||
},
|
||||
now: () => 10,
|
||||
});
|
||||
await directory.addMembership({ tenantId: "acme", userId: "u1", workspaceIds: ["north"] });
|
||||
expect(await directory.switchWorkspace("acme", "u1", "north")).toEqual({
|
||||
tenantId: "acme",
|
||||
workspaceId: "north",
|
||||
});
|
||||
await expect(directory.switchWorkspace("acme", "u1", "south")).rejects.toThrow(
|
||||
"WRN-TENANT-WORKSPACE-DENIED",
|
||||
);
|
||||
directory.setQuota("acme", "storage", 100);
|
||||
expect(() => directory.enforceQuota("acme", "storage", 90, 11)).toThrow("WRN-TENANT-QUOTA");
|
||||
expect(() => assertTenantAccess({ id: "acme" }, { tenantId: "other" })).toThrow(
|
||||
"WRN-TENANT-CROSS-ACCESS",
|
||||
);
|
||||
expect(tenantKey("acme", "cache", 1)).toBe("tenant:acme:cache:1");
|
||||
expect(events).toEqual(["membership.added", "workspace.switched"]);
|
||||
});
|
||||
|
||||
test("loaders, actions, and request-local dedupe remain framework-agnostic", async () => {
|
||||
let calls = 0;
|
||||
const loader = defineLoader({ load: async () => ({ ready: true }) });
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
Bulkhead,
|
||||
CircuitBreaker,
|
||||
ResilienceError,
|
||||
durationMs,
|
||||
resilientCall,
|
||||
} from "../src/index.ts";
|
||||
|
||||
describe("resilience primitives", () => {
|
||||
test("parses durations and validates bad configuration", () => {
|
||||
expect(durationMs("1.5s")).toBe(1_500);
|
||||
expect(durationMs("2m")).toBe(120_000);
|
||||
expect(() => durationMs("soon" as never)).toThrow("Invalid duration");
|
||||
});
|
||||
|
||||
test("retries with exponential backoff and reports attempts", async () => {
|
||||
const waits: number[] = [];
|
||||
let calls = 0;
|
||||
const value = await resilientCall({
|
||||
retries: 2,
|
||||
retryDelay: 1,
|
||||
backoff: "exponential",
|
||||
onRetry: (_error, _attempt, wait) => waits.push(wait),
|
||||
run: async (_signal, attempt) => {
|
||||
calls += 1;
|
||||
if (attempt < 3) throw new Error("temporary");
|
||||
return "ready";
|
||||
},
|
||||
});
|
||||
expect(value).toBe("ready");
|
||||
expect(calls).toBe(3);
|
||||
expect(waits).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test("times out cooperative operations and supports fallback", async () => {
|
||||
const value = await resilientCall({
|
||||
timeout: "5ms",
|
||||
fallback: (error) => (error as ResilienceError).code,
|
||||
run: (signal) =>
|
||||
new Promise((_resolve, reject) =>
|
||||
signal.addEventListener("abort", () => reject(signal.reason)),
|
||||
),
|
||||
});
|
||||
expect(value).toBe("WRN-RESILIENCE-TIMEOUT");
|
||||
});
|
||||
|
||||
test("times out integrations that ignore cancellation", async () => {
|
||||
await expect(
|
||||
resilientCall({ timeout: "2ms", run: () => new Promise(() => {}) }),
|
||||
).rejects.toMatchObject({ code: "WRN-RESILIENCE-TIMEOUT" });
|
||||
});
|
||||
|
||||
test("opens a circuit and exposes health", async () => {
|
||||
const breaker = new CircuitBreaker({ failures: 2, resetAfter: "1h" });
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await expect(
|
||||
breaker.execute(async () => {
|
||||
throw new Error("down");
|
||||
}),
|
||||
).rejects.toThrow("down");
|
||||
}
|
||||
expect(breaker.snapshot().state).toBe("open");
|
||||
await expect(breaker.execute(async () => "nope")).rejects.toMatchObject({
|
||||
code: "WRN-RESILIENCE-CIRCUIT-OPEN",
|
||||
});
|
||||
});
|
||||
|
||||
test("retains circuit state for a reused declarative configuration", async () => {
|
||||
const circuitBreaker = { failures: 1, resetAfter: "1h" } as const;
|
||||
await expect(
|
||||
resilientCall({
|
||||
circuitBreaker,
|
||||
run: async () => {
|
||||
throw new Error("down");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("down");
|
||||
await expect(
|
||||
resilientCall({ circuitBreaker, run: async () => "unreachable" }),
|
||||
).rejects.toMatchObject({ code: "WRN-RESILIENCE-CIRCUIT-OPEN" });
|
||||
});
|
||||
|
||||
test("bulkhead bounds concurrency and queue depth", async () => {
|
||||
const bulkhead = new Bulkhead({ concurrency: 1, queue: 1 });
|
||||
let release!: () => void;
|
||||
const first = bulkhead.execute(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const second = bulkhead.execute(async () => "second");
|
||||
await expect(bulkhead.execute(async () => "third")).rejects.toMatchObject({
|
||||
code: "WRN-RESILIENCE-BULKHEAD-FULL",
|
||||
});
|
||||
expect(bulkhead.snapshot).toEqual({ active: 1, queued: 1, capacity: 1 });
|
||||
release();
|
||||
await first;
|
||||
expect(await second).toBe("second");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
createPersistentTenantDirectory,
|
||||
memoryTenantDirectoryStore,
|
||||
migrateTenants,
|
||||
postgresTenantDirectoryStore,
|
||||
} from "../src/index.ts";
|
||||
|
||||
test("persistent tenant directory stores memberships, workspace access and quota usage", async () => {
|
||||
const events: string[] = [];
|
||||
const directory = createPersistentTenantDirectory(memoryTenantDirectoryStore(), {
|
||||
audit: (event) => {
|
||||
events.push(event.action);
|
||||
},
|
||||
});
|
||||
await directory.addMembership({
|
||||
tenantId: "acme",
|
||||
userId: "u1",
|
||||
roles: ["admin"],
|
||||
workspaceIds: ["w1"],
|
||||
});
|
||||
expect(await directory.membership("acme", "u1")).toMatchObject({ roles: ["admin"] });
|
||||
expect(await directory.switchWorkspace("acme", "u1", "w1")).toEqual({
|
||||
tenantId: "acme",
|
||||
workspaceId: "w1",
|
||||
});
|
||||
await directory.setQuota("acme", "projects", 2);
|
||||
expect(await directory.consumeQuota("acme", "projects", 1)).toMatchObject({ usage: 1 });
|
||||
await expect(directory.consumeQuota("acme", "projects", 2)).rejects.toThrow("QUOTA");
|
||||
expect(events).toEqual(["membership.added", "workspace.switched"]);
|
||||
});
|
||||
|
||||
test("tenant migration orchestrator bounds concurrency and reports isolated failures", async () => {
|
||||
let active = 0,
|
||||
peak = 0;
|
||||
const result = await migrateTenants(
|
||||
[{ id: "a" }, { id: "b" }, { id: "bad" }],
|
||||
async (tenant) => {
|
||||
active++;
|
||||
peak = Math.max(peak, active);
|
||||
await Promise.resolve();
|
||||
active--;
|
||||
if (tenant.id === "bad") throw new Error("migration failed");
|
||||
},
|
||||
{ concurrency: 2, continueOnError: true },
|
||||
);
|
||||
expect(result.migrated.sort()).toEqual(["a", "b"]);
|
||||
expect(result.failed[0]?.tenantId).toBe("bad");
|
||||
expect(peak).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("PostgreSQL tenant store parameterizes identities", async () => {
|
||||
const calls: unknown[][] = [];
|
||||
const store = postgresTenantDirectoryStore({
|
||||
async query<T>(_sql: string, params?: unknown[]) {
|
||||
calls.push(params ?? []);
|
||||
return { rows: [] as T[] };
|
||||
},
|
||||
});
|
||||
await store.putMembership({ tenantId: "tenant", userId: "user" });
|
||||
expect(calls[0]?.slice(0, 2)).toEqual(["tenant", "user"]);
|
||||
});
|
||||
Reference in New Issue
Block a user