@kosan/core
The ORM-agnostic foundation. Contains the registry, connection cache, request context, and resolver interfaces. All other packages depend on this one.
npm install @kosan/coreTenantRegistry
The central coordinator. Owns the master store, connection cache, and lifecycle hooks.
TenantRegistry.create(options)
import { TenantRegistry } from '@kosan/core';
const registry = await TenantRegistry.create({
master, // MasterStore implementation
adapter, // Adapter<TConn> implementation
cache: {
maxSize: 100, // default: 100
idleTimeoutMs: 1_800_000, // default: 30 min
},
cipher, // optional Cipher for credential encryption
hooks: {
onCreate, // optional lifecycle hooks
onDelete,
onSuspend,
},
});Tenant CRUD
// Create
const tenant = await registry.createTenant({
slug: 'acme',
host: 'db.acme.com',
port: 5432,
dbName: 'acme',
user: 'app',
password: 'secret',
meta: { plan: 'enterprise' },
});
// Read
const tenant = await registry.getTenant(id); // by id
const tenants = await registry.listTenants(); // all
const active = await registry.listTenants({ status: 'active' });
// Update
await registry.updateTenant(id, { host: 'new-host.db.com' });
// Suspend (evicts cached connection, triggers onSuspend hook)
await registry.suspendTenant(id);
// Delete (evicts cached connection, triggers onDelete hook)
await registry.deleteTenant(id);Resolution (used by middleware)
// Returns TenantContextValue — used internally by middleware
const ctx = await registry.resolveBySlug('acme');
const ctx = await registry.resolveById('uuid-...');Model registration
registry.registerModels([OrderModel, UserModel]);Observability
const stats = registry.getStats();
// {
// cacheSize: 3,
// cacheMaxSize: 100,
// entries: [
// { tenantId: '...', tenantSlug: 'acme', lastUsed: 1715000000000, idleMs: 4200 },
// ...
// ]
// }Shutdown
// Gracefully closes all cached connections.
await registry.shutdown();ConnectionCache
Manages open connections with LRU eviction and idle-timeout sweeping. Exposed on registry.cache.
const cache = registry.cache;
cache.size; // number of open connections
cache.maxSize; // configured limit
cache.has('tenant-id'); // boolean
cache.stats(); // CacheStats[]
await cache.evict('tenant-id'); // close one connection
await cache.disconnectAll(); // close all (called by registry.shutdown())CacheStats
interface CacheStats {
tenantId: string;
tenantSlug: string;
lastUsed: number; // Unix ms timestamp
idleMs: number; // ms since last use
}TenantContext
Request-scoped context powered by AsyncLocalStorage. Once the middleware runs runWithTenant(ctx, fn), any code inside fn — regardless of call depth — can call useTenant() to get the current tenant's connection and models.
runWithTenant(ctx, fn)
import { runWithTenant } from '@kosan/core';
// Used internally by middleware — you rarely call this directly.
await runWithTenant({ tenant, connection, models }, async () => {
// Everything inside this closure has access to useTenant().
await processOrder();
});useTenant<TConn>()
import { useTenant } from '@kosan/core';
function processOrder() {
const { tenant, connection, models } = useTenant();
// tenant — TenantConfig (id, slug, host, …)
// connection — raw ORM connection (Sequelize instance, PrismaClient, …)
// models — record of registered models
}Throws Error if called outside a tenant context.
getCurrentTenant()
import { getCurrentTenant } from '@kosan/core';
// Returns TenantConfig | undefined — safe to call anywhere, won't throw.
const tenant = getCurrentTenant();wrapWithTenantContext(ctx, callback)
For frameworks with callback-style middleware (Fastify's onRequest hook). Runs callback inside storage.run(ctx, callback) so the framework's continuation inherits the context.
import { wrapWithTenantContext } from '@kosan/core';
// Fastify example (simplified):
fastify.addHook('onRequest', (req, reply, done) => {
wrapWithTenantContext(tenantCtx, done);
});Resolvers
Built-in implementations of the Resolver interface.
SubdomainResolver
Extracts the first subdomain label and uses it as the tenant slug.
import { SubdomainResolver } from '@kosan/core';
// acme.myapp.com → "acme"
// www.myapp.com → null (configured via ignoredSubdomains)
const resolver = new SubdomainResolver({
ignoredSubdomains: ['www', 'api', 'app'], // default: ['www']
});HeaderResolver
Reads a custom HTTP header.
import { HeaderResolver } from '@kosan/core';
// X-Tenant-ID: acme → "acme"
const resolver = new HeaderResolver('X-Tenant-ID');PathResolver
Extracts a URL path segment by index.
import { PathResolver } from '@kosan/core';
// /acme/orders → "acme" (segment 0)
// /api/acme/v1 → "acme" (segment 1)
const resolver = new PathResolver({ segment: 0 });Custom resolver
Pass a plain async function anywhere a Resolver is accepted:
app.use(tenantMiddleware({
registry,
resolver: async (req) => {
// Extract slug from a JWT claim, session, or any other source.
const payload = verifyToken(req.headers.authorization);
return payload?.tenantSlug ?? null;
},
}));Observability helpers
getTenantLogContext()
Returns structured log fields for the current tenant. Returns {} when called outside a tenant context (safe to call anywhere).
import { getTenantLogContext } from '@kosan/core';
import pino from 'pino';
const logger = pino();
app.use((req, res, next) => {
req.log = logger.child(getTenantLogContext());
next();
});
// Inside a handler:
// req.log.info('order created')
// → { tenantId: 'uuid-...', tenantSlug: 'acme', msg: 'order created' }getHealthPayload(registry)
Serialisable snapshot of the cache state — drop it directly into a /health response.
import { getHealthPayload } from '@kosan/core';
app.get('/health', (_req, res) => {
res.json(getHealthPayload(registry));
});
// Response:
// {
// "cacheSize": 3,
// "cacheMaxSize": 100,
// "tenantCount": 3,
// "entries": [
// { "tenantId": "...", "tenantSlug": "acme", "lastUsed": 1715000000000, "idleMs": 120 }
// ]
// }Type reference
interface TenantConfig {
id: string;
slug: string;
host: string;
port: number;
dbName: string;
user: string;
password: string;
status: 'active' | 'suspended' | 'deleted';
meta?: Record<string, unknown>;
createdAt?: Date;
updatedAt?: Date;
}
interface MasterStore {
findAll(filter?: { status?: TenantStatus }): Promise<TenantConfig[]>;
findBySlug(slug: string): Promise<TenantConfig | null>;
findById(id: string): Promise<TenantConfig | null>;
create(data: CreateTenantInput): Promise<TenantConfig>;
update(id: string, data: UpdateTenantInput): Promise<TenantConfig>;
delete(id: string): Promise<void>;
}
interface Adapter<TConn> {
connect(tenant: TenantConfig): Promise<TConn>;
disconnect(conn: TConn): Promise<void>;
getModels(conn: TConn): Record<string, unknown>;
registerModelFactories?(factories: ModelFactory<TConn>[]): void;
}
interface Resolver {
resolve(req: unknown): string | null | Promise<string | null>;
}
interface TenantContextValue<TConn = unknown> {
tenant: TenantConfig;
connection: TConn;
models: Record<string, unknown>;
}
interface Cipher {
encrypt(plain: string): Promise<string>;
decrypt(ciphertext: string): Promise<string>;
}
interface LifecycleHooks<TConn> {
onCreate?(tenant: TenantConfig, conn: TConn): Promise<void>;
onDelete?(tenant: TenantConfig): Promise<void>;
onSuspend?(tenant: TenantConfig): Promise<void>;
}Error classes
import { TenantNotFoundError, TenantNotActiveError } from '@kosan/core';
try {
await registry.resolveBySlug('unknown');
} catch (err) {
if (err instanceof TenantNotFoundError) {
// slug or id was not found in the master store
}
if (err instanceof TenantNotActiveError) {
console.log(err.status); // 'suspended' | 'deleted'
}
}