release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-02 23:18:51 +05:30
parent 87507edf59
commit 586a6db8ff
625 changed files with 243608 additions and 11210 deletions
+31
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.7.0",
"version": "0.8.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+49 -5
View File
@@ -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) {
+139
View File
@@ -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 ?? {}) },
});
}
+54 -2
View File
@@ -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,
+7 -7
View File
@@ -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 };
}
+257
View File
@@ -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();
}
+308
View File
@@ -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 }) });
+102
View File
@@ -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"]);
});